// GarageUI overlay family — dialogs, toasts, tooltips, popover. // // hand rolled replacement for the shadcn overlay widgets the app leans on. // the visuals here are copied 1:1 from shadcn_flutter 0.0.52 (dialog.dart, // toast.dart, tooltip.dart, popover.dart) so nothing shifts by a pixel when // call sites swap their import. the show/host plumbing is self contained and // runs on flutter's plain Overlay/OverlayEntry — no root ToastLayer needed. // // we still import shadcn for Theme.of / colorScheme / the little text helpers // (.large(), .muted(), .gap() ...). that import gets repointed later in the // migration, the spec allows it for now. import "dart:async"; import "dart:math"; import "package:flutter/widgets.dart"; import "package:garage_ui/theme/garage_theme.dart"; import "package:garage_ui/theme/support.dart"; import "package:garage_ui/surface.dart" show Divider, SurfaceCard; // --------------------------------------------------------------------------- // dialog surface primitives (from overlay/dialog.dart) // --------------------------------------------------------------------------- /// Paints the dim barrier behind a modal, optionally cutting a rounded hole /// out around the dialog surface. straight port of shadcn's painter. class SurfaceBarrierPainter extends CustomPainter { static const double bigSize = 1000000; static const bigScreen = Size(bigSize, bigSize); static const bigOffset = Offset(-bigSize / 2, -bigSize / 2); final bool clip; final BorderRadius borderRadius; final Color barrierColor; final EdgeInsets padding; SurfaceBarrierPainter({ required this.clip, required this.borderRadius, required this.barrierColor, this.padding = EdgeInsets.zero, }); Rect _padRect(Rect rect) { return Rect.fromLTRB( rect.left + padding.left, rect.top + padding.top, rect.right - padding.right, rect.bottom - padding.bottom, ); } @override void paint(Canvas canvas, Size size) { final Paint paint = Paint() ..color = barrierColor ..blendMode = BlendMode.srcOver ..style = PaintingStyle.fill; if (clip) { var rect = (Offset.zero & size); rect = _padRect(rect); Path path = Path() ..addRect(bigOffset & bigScreen) ..addRRect( RRect.fromRectAndCorners( rect, topLeft: borderRadius.topLeft, topRight: borderRadius.topRight, bottomLeft: borderRadius.bottomLeft, bottomRight: borderRadius.bottomRight, ), ); path.fillType = PathFillType.evenOdd; canvas.clipPath(path); } canvas.drawRect(bigOffset & bigScreen, paint); } @override bool shouldRepaint(covariant SurfaceBarrierPainter oldDelegate) { return oldDelegate.borderRadius != borderRadius || oldDelegate.barrierColor != barrierColor || oldDelegate.padding != padding || oldDelegate.clip != clip; } } /// Semi transparent barrier + spotlight cutout drawn behind modal content. class ModalBackdrop extends StatelessWidget { static bool shouldClipSurface(double? surfaceOpacity) { if (surfaceOpacity == null) return true; return surfaceOpacity < 1; } final Widget child; final BorderRadiusGeometry borderRadius; final EdgeInsetsGeometry padding; final Color barrierColor; final bool modal; final bool surfaceClip; const ModalBackdrop({ super.key, this.modal = true, this.surfaceClip = true, this.borderRadius = BorderRadius.zero, this.barrierColor = const Color.fromRGBO(0, 0, 0, 0.8), this.padding = EdgeInsets.zero, required this.child, }); @override Widget build(BuildContext context) { if (!modal) return child; final textDirection = Directionality.of(context); final resolvedBorderRadius = borderRadius.resolve(textDirection); final resolvedPadding = padding.resolve(textDirection); final Widget paintWidget = CustomPaint( painter: SurfaceBarrierPainter( clip: surfaceClip, borderRadius: resolvedBorderRadius, barrierColor: barrierColor, padding: resolvedPadding, ), ); return RepaintBoundary( child: Stack( fit: StackFit.passthrough, clipBehavior: Clip.none, children: [ if (!surfaceClip) Positioned.fill(child: IgnorePointer(child: paintWidget)), child, if (surfaceClip) Positioned.fill(child: IgnorePointer(child: paintWidget)), ], ), ); } } /// Consistent surface styling for modal content. thin wrapper over SurfaceCard /// (we drop shadcn's fullscreen Model plumbing — no dialog in the app is /// fullscreen). class ModalContainer extends StatelessWidget { 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; final double? surfaceOpacity; final double? surfaceBlur; final Duration? duration; const ModalContainer({ super.key, required this.child, this.padding, this.filled = false, this.fillColor, this.borderRadius, this.clipBehavior = Clip.none, this.borderColor, this.borderWidth, this.boxShadow, this.surfaceOpacity, this.surfaceBlur, this.duration, }); @override Widget build(BuildContext context) { return SurfaceCard( clipBehavior: clipBehavior, borderRadius: borderRadius, borderWidth: borderWidth, borderColor: borderColor, filled: filled, fillColor: fillColor, boxShadow: boxShadow, padding: padding, surfaceOpacity: surfaceOpacity, surfaceBlur: surfaceBlur, duration: duration, child: child, ); } } // --------------------------------------------------------------------------- // AlertDialog (from layout/dialog/alert_dialog.dart) // --------------------------------------------------------------------------- /// Modal alert / confirmation dialog. layout copied verbatim from shadcn so /// padding, gaps, radii and text styling all line up. /// How wide a dialog gets before its content starts wrapping. /// /// A default rather than something every call site picks: a confirmation is /// the same shape wherever it turns up, and left to size themselves the wide /// ones came out as a single line of text across the window while the narrow /// ones came out as a column - the same component reading as two. /// /// Roughly fifty characters at the product tier, which is the width a short /// sentence wants to wrap at. Override per dialog where somethings genuinely /// wider - a table, a form - rather than moving this. const double kDialogMaxWidth = 350; class AlertDialog extends StatefulWidget { final Widget? leading; final Widget? trailing; final Widget? title; final Widget? content; final List? actions; final double? surfaceBlur; final double? surfaceOpacity; final Color? barrierColor; final EdgeInsetsGeometry? padding; /// A ceiling on how wide the dialog gets. Defaults to /// [kDialogMaxWidth]; pass your own for the odd one that needs it, or /// `double.infinity` to let it size to its content. /// /// Without one it sizes to its widest line, and for a one sentence /// confirmation thats the whole sentence - a dialog the width of the window /// carrying twelve words. A cap makes the sentence wrap into a block, which /// is the shape a dialog is supposed to be. final double maxWidth; const AlertDialog({ super.key, this.leading, this.title, this.content, this.actions, this.trailing, this.surfaceBlur, this.surfaceOpacity, this.barrierColor, this.padding, this.maxWidth = kDialogMaxWidth, }); @override State createState() => _AlertDialogState(); } class _AlertDialogState extends State { @override Widget build(BuildContext context) { final themeData = GarageTheme.of(context); final scaling = themeData.scaling; final densityGap = themeData.density.containerGap; final densityContainerPadding = themeData.density.containerPadding; final dialogHorizontalPadding = densityContainerPadding * 1.4; // a screen reader has to be told it entered a dialog - without // scopesRoute the box is just more content stacked over the page behind // it, and the reading order walks straight out of it. return Semantics( scopesRoute: true, namesRoute: true, explicitChildNodes: true, child: ModalBackdrop( borderRadius: themeData.borderRadiusXxl, barrierColor: widget.barrierColor ?? const Color(0xcc000000), surfaceClip: ModalBackdrop.shouldClipSurface( widget.surfaceOpacity ?? themeData.surfaceOpacity, ), child: ModalContainer( // card, NOT popover. // // The two are opposite ends of the ladder: card is raise(5.9), a // surface ABOVE the ground, and popover is sink(11.6), one below it. // A menu is anchored to the thing that opened it and can afford to // sit under the page; a dialog floats over a dimmed one and has to // read as on top of it. // // On carbon that was the whole bug. Carbon pins popover to the floor // - pure black, deliberately, because the gutter between panels is // the look - so a dialog came out 5.5 L* DARKER than the page behind // it and only the hairline border said it was there at all. card // puts it 5.9 above instead. fillColor: themeData.colorScheme.card, filled: true, borderRadius: themeData.borderRadiusXxl, borderWidth: 1 * scaling, borderColor: themeData.colorScheme.propertiesSectionBorder, // no padding on the container itself - the actions band has to run // edge to edge, and a padded container would inset it like any other // child. The content pads itself below instead. padding: EdgeInsets.zero, clipBehavior: Clip.antiAlias, surfaceBlur: widget.surfaceBlur ?? themeData.surfaceBlur, surfaceOpacity: widget.surfaceOpacity ?? themeData.surfaceOpacity, // IntrinsicWidth, because the column below stretches. // // The actions band has to span the dialog edge to edge, which needs // CrossAxisAlignment.stretch - and stretch takes every pixel its // offered, so the dialog went the full width of the window. Resolved // out here, the width is the widest thing in it (the message, // usually) and the band then stretches to THAT. child: ConstrainedBox( constraints: BoxConstraints(maxWidth: widget.maxWidth), child: IntrinsicWidth( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Flexible( child: Padding( padding: widget.padding ?? EdgeInsets.only( left: dialogHorizontalPadding, right: dialogHorizontalPadding, top: densityContainerPadding * 1.2, bottom: densityContainerPadding * 0.6, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (widget.leading != null) widget.leading!.overlayIconLarge .iconMutedForeground(), if (widget.title != null || widget.content != null) Flexible( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.title != null) widget.title!.xSmall().semiBold(), if (widget.content != null) widget.content!.xSmall().muted(), ], ).gap(densityGap), ), if (widget.trailing != null) widget.trailing!.overlayIconLarge .iconMutedForeground(), ], ).gap(dialogHorizontalPadding), ), ), // the same two tone band a PropertiesSection puts its actions // in: a rule, then a fill part way from the surface to muted, so // the buttons read as the dialog acting on itself rather than as // one more thing sitting in its body. // // Lerped off the dialog's own fill, not pinned to a token, so it // tracks whatever the surface turns out to be - the same reason // the section does it that way. if (widget.actions != null && widget.actions!.isNotEmpty) ...[ Divider( color: themeData.colorScheme.propertiesSectionBorder, ), Container( color: Color.lerp( themeData.colorScheme.card, themeData.colorScheme.muted, 0.45, ), padding: EdgeInsets.symmetric( horizontal: themeData.density.buttonPaddingX, vertical: themeData.density.gapSm, ), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: join( widget.actions!, SizedBox(width: densityGap), ).toList(), ), ), ], ], ), ), ), ), ), ); } } // --------------------------------------------------------------------------- // showDialog (from overlay/dialog.dart) — plain RawDialogRoute, self contained // --------------------------------------------------------------------------- Widget _buildDialogTransitions( BuildContext context, AlignmentGeometry alignment, Animation animation, bool fullScreen, Widget child, ) { final scaleTransition = ScaleTransition( scale: CurvedAnimation( parent: animation.drive(Tween(begin: 0.7, end: 1.0)), curve: Curves.easeOut, reverseCurve: Curves.easeIn, ), child: FadeTransition( opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), child: child, ), ); return FocusScope( child: fullScreen ? scaleTransition : Align(alignment: alignment, child: scaleTransition), ); } /// Pushes a modal dialog onto the root navigator. matches shadcn's timing /// (150ms) and transition (scale 0.7→1 easeOut + fade). the barrier itself is /// transparent here — [AlertDialog] paints its own dim backdrop, same as /// upstream. Future showDialog({ required BuildContext context, required WidgetBuilder builder, bool useRootNavigator = true, bool barrierDismissible = true, Color? barrierColor, String? barrierLabel, bool useSafeArea = true, RouteSettings? routeSettings, Offset? anchorPoint, TraversalEdgeBehavior? traversalEdgeBehavior, AlignmentGeometry? alignment, bool fullScreen = false, }) { final navigatorState = Navigator.of(context, rootNavigator: useRootNavigator); final CapturedThemes themes = InheritedTheme.capture( from: context, to: navigatorState.context, ); final resolvedAlignment = alignment ?? Alignment.center; return navigatorState.push( RawDialogRoute( pageBuilder: (buildContext, animation, secondaryAnimation) { Widget pageChild = Builder( builder: (context) { final theme = GarageTheme.of(context); final densityContainerPadding = theme.density.containerPadding; return Padding( padding: fullScreen ? EdgeInsets.zero : EdgeInsets.all(densityContainerPadding), child: builder(context), ); }, ); Widget dialog = themes.wrap(pageChild); if (useSafeArea) { dialog = SafeArea(child: dialog); } return dialog; }, barrierDismissible: barrierDismissible, barrierColor: barrierColor ?? const Color.fromRGBO(0, 0, 0, 0), barrierLabel: barrierLabel ?? "Dismiss", settings: routeSettings, anchorPoint: anchorPoint, traversalEdgeBehavior: traversalEdgeBehavior ?? TraversalEdgeBehavior.closedLoop, transitionDuration: const Duration(milliseconds: 150), transitionBuilder: (context, animation, secondaryAnimation, child) { return _buildDialogTransitions( context, resolvedAlignment, animation, fullScreen, child, ); }, ), ); } // --------------------------------------------------------------------------- // TooltipContainer + Tooltip (from overlay/tooltip.dart) // --------------------------------------------------------------------------- /// Styled bubble used inside tooltips. Padding/radius/blur follow the theme; /// the label uses typography.small - the same density-derived control font as /// buttons and fields - so a tooltip matches whatever it is describing. class TooltipContainer extends StatelessWidget { final Widget child; final double? surfaceOpacity; final double? surfaceBlur; final EdgeInsetsGeometry? padding; final Color? backgroundColor; final Color? borderColor; final BorderRadiusGeometry? borderRadius; const TooltipContainer({ super.key, this.surfaceOpacity, this.surfaceBlur, this.padding, this.backgroundColor, this.borderColor, this.borderRadius, required this.child, }); Widget call(BuildContext context) => this; @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final densityGap = theme.density.containerGap; final densityContentPadding = theme.density.containerPadding; Color backgroundColor = this.backgroundColor ?? theme.colorScheme.tooltipBackground; final surfaceOpacity = this.surfaceOpacity; final surfaceBlur = this.surfaceBlur; if (surfaceOpacity != null) { backgroundColor = backgroundColor.scaleAlpha(surfaceOpacity); } final padding = this.padding ?? EdgeInsets.symmetric( horizontal: densityContentPadding * 0.75, vertical: densityGap * 0.75, ); final resolvedPadding = resolveEdgeInsets( padding, densityContentPadding, ).resolve(Directionality.of(context)); final borderRadius = this.borderRadius ?? BorderRadius.circular(theme.radiusSm); Widget animatedContainer = Container( padding: resolvedPadding, decoration: BoxDecoration( color: backgroundColor, border: Border.all( color: borderColor ?? theme.colorScheme.tooltipBorder, ), borderRadius: borderRadius, ), // typography.small is the shared control font (buttons, fields, // selects) and comes off the density, so a tooltip now matches the // thing it describes and tracks density changes with it. xSmall was // 12 * scaling - two sizes and two systems away from its trigger. // foreground, NOT primaryForeground. tooltipBackground is derived from // `background`, so it pairs with the same label colour every other // surface off `background` uses - both foregrounds collapsed into it // are both plain `foreground` for exactly this reason. primaryForeground // is the colour that sits on `primary`, and on any scheme where primary // is light that makes it dark: black text on a dark bubble. child: DefaultTextStyle.merge( style: theme.typography.small.copyWith( color: theme.colorScheme.foreground, ), child: IconTheme.merge( data: IconThemeData(color: theme.colorScheme.foreground), child: child, ), ), ); if (surfaceBlur != null && surfaceBlur > 0) { animatedContainer = SurfaceBlur( surfaceBlur: surfaceBlur, borderRadius: borderRadius, child: animatedContainer, ); } return Padding( padding: EdgeInsets.all(densityGap * 0.75), child: animatedContainer, ); } } /// Hover activated tooltip. same timing knobs as shadcn (wait/show/min) and /// same positioning via the popover layout, but the hover detection + overlay /// host are self contained (flutter Overlay through [FixedTooltipOverlayHandler]). // how little room a tooltip needs on its preferred side before it gives up and // flips. tooltips shrink-wrap, so this is a rule of thumb rather than a // measurement - roughly a couple of lines plus padding. const double _kTooltipFlipThreshold = 48.0; class Tooltip extends StatefulWidget { final Widget child; final WidgetBuilder tooltip; final AlignmentGeometry alignment; final AlignmentGeometry anchorAlignment; final Duration waitDuration; final Duration showDuration; final Duration minDuration; const Tooltip({ super.key, required this.child, required this.tooltip, this.alignment = Alignment.topCenter, this.anchorAlignment = Alignment.bottomCenter, this.waitDuration = const Duration(milliseconds: 500), this.showDuration = const Duration(milliseconds: 200), this.minDuration = const Duration(milliseconds: 0), }); @override State createState() => _TooltipState(); } // hover timing lifted from shadcn's Hover widget so the feel is identical: an // AnimationController whose forward duration is waitDuration (delay before // show) and whose reverse duration flips between minDuration and showDuration // depending on whether the cursor actually left. class _TooltipState extends State with SingleTickerProviderStateMixin { final LayerLink _link = LayerLink(); OverlayEntry? _entry; late AnimationController _hover; int? _enterTime; @override void initState() { super.initState(); _hover = AnimationController(vsync: this, duration: widget.waitDuration); _hover.addStatusListener(_onStatus); } void _onStatus(AnimationStatus status) { if (status == AnimationStatus.completed) { _show(); } else if (status == AnimationStatus.dismissed) { _hide(); } } void _onEnter() { _enterTime = DateTime.now().millisecondsSinceEpoch; _hover.duration = widget.waitDuration; _hover.forward(); } void _onExit() { final minDuration = widget.minDuration.inMilliseconds; final enterTime = _enterTime; if (enterTime != null) { final elapsed = DateTime.now().millisecondsSinceEpoch - enterTime; _hover.reverseDuration = Duration( milliseconds: elapsed < minDuration ? minDuration : 0, ); _hover.reverse(); } _enterTime = null; } void _show() { if (_entry != null) return; // the ROOT overlay, like the select popup and every other floating thing // in here. Overlay.of finds the nearest one, and go_router's ShellRoute // puts its Navigator - and so its overlay - inside the shell's layout, // which in the hub means inside the pane's scroll view. A tooltip landing // there gets clipped by the scroller the moment it reaches past the // column, which looks like the list is clipping it and isnt. final overlay = Overlay.of(context, rootOverlay: true); // a tooltip anchored below the trigger has to flip above it near the // bottom edge, or it gets clipped by the viewport. the caller's alignment // still decides the PREFERRED side - this only overrides it when that // side doesnt fit. final anchorAlignment = _asAlignment(widget.anchorAlignment); final followerAlignment = _asAlignment(widget.alignment); final target = context.findRenderObject() as RenderBox?; final overlayBox = overlay.context.findRenderObject() as RenderBox?; Alignment resolvedAnchor = anchorAlignment; Alignment resolvedFollower = followerAlignment; if (target != null && target.hasSize && overlayBox != null) { final targetOffset = target.localToGlobal( Offset.zero, ancestor: overlayBox, ); // tooltips shrink-wrap their label, so theres no size to measure up // front - fall back to "is there more room on the other side" final roomBelow = overlayBox.size.height - targetOffset.dy - target.size.height; final roomAbove = targetOffset.dy; final prefersBelow = anchorAlignment.y > 0; final flip = prefersBelow ? roomBelow < _kTooltipFlipThreshold && roomAbove > roomBelow : roomAbove < _kTooltipFlipThreshold && roomBelow > roomAbove; if (flip) { resolvedAnchor = Alignment(anchorAlignment.x, -anchorAlignment.y); resolvedFollower = Alignment(followerAlignment.x, -followerAlignment.y); } } _entry = OverlayEntry( builder: (ctx) { // Positioned (left/top only, no width/height) is what makes the // constraints LOOSE. Without it the entry is laid out tight to the // whole overlay, the Align below cant shrink wrap, and the follower // ends up anchoring a screen-sized box - which put the label half a // viewport away from its trigger instead of under it. return Positioned( left: 0, top: 0, // tooltip floats near the anchor, ignores pointer so it never eats hover. child: IgnorePointer( child: CompositedTransformFollower( link: _link, showWhenUnlinked: false, targetAnchor: resolvedAnchor, followerAnchor: resolvedFollower, child: Align( widthFactor: 1, heightFactor: 1, child: widget.tooltip(ctx), ), ), ), ); }, ); overlay.insert(_entry!); } void _hide() { _entry?.remove(); _entry = null; } @override void dispose() { _hide(); _hover.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return CompositedTransformTarget( link: _link, child: MouseRegion( hitTestBehavior: HitTestBehavior.deferToChild, onEnter: (_) => _onEnter(), onExit: (_) => _onExit(), child: widget.child, ), ); } } // the tooltip alignments are always plain Alignments in practice; resolve just // in case a directional one slips through. Alignment _asAlignment(AlignmentGeometry a) => a is Alignment ? a : a.resolve(TextDirection.ltr); // --------------------------------------------------------------------------- // Toasts (from overlay/toast.dart) — self contained Overlay host // --------------------------------------------------------------------------- /// Where on screen a toast stack lives, and which way new toasts stack. enum ToastLocation { topLeft( childrenAlignment: Alignment.bottomCenter, alignment: Alignment.topLeft, ), topCenter( childrenAlignment: Alignment.bottomCenter, alignment: Alignment.topCenter, ), topRight( childrenAlignment: Alignment.bottomCenter, alignment: Alignment.topRight, ), bottomLeft( childrenAlignment: Alignment.topCenter, alignment: Alignment.bottomLeft, ), bottomCenter( childrenAlignment: Alignment.topCenter, alignment: Alignment.bottomCenter, ), bottomRight( childrenAlignment: Alignment.topCenter, alignment: Alignment.bottomRight, ); final Alignment alignment; final Alignment childrenAlignment; const ToastLocation({ required this.alignment, required this.childrenAlignment, }); } /// Control handle handed to the toast builder + returned from [showToast]. abstract class ToastOverlay { bool get isShowing; void close(); } typedef ToastBuilder = Widget Function(BuildContext context, ToastOverlay overlay); /// Shows a toast in the nearest root [Overlay]. no ToastLayer required — we /// lazily insert our own host entry. timing matches shadcn: 500ms easeOutCubic /// entry, 5s auto dismiss, swipe to dismiss. ToastOverlay showToast({ required BuildContext context, required ToastBuilder builder, ToastLocation location = ToastLocation.bottomRight, bool dismissible = true, Curve curve = Curves.easeOutCubic, Duration entryDuration = const Duration(milliseconds: 500), VoidCallback? onClosed, Duration showDuration = const Duration(seconds: 5), }) { final overlay = Overlay.of(context, rootOverlay: true); final CapturedThemes themes = InheritedTheme.capture( from: context, to: overlay.context, ); final host = _ToastHostRegistry.of(overlay); return host.add( _ToastData( builder: builder, location: location, dismissible: dismissible, curve: curve, entryDuration: entryDuration, onClosed: onClosed, showDuration: showDuration, themes: themes, ), ); } class _ToastData { final ToastBuilder builder; final ToastLocation location; final bool dismissible; final Curve curve; final Duration entryDuration; final VoidCallback? onClosed; final Duration showDuration; final CapturedThemes themes; _ToastData({ required this.builder, required this.location, required this.dismissible, required this.curve, required this.entryDuration, required this.onClosed, required this.showDuration, required this.themes, }); } // one host per Overlay. we keep the host entry alive for the app's lifetime — // it renders nothing when there are no toasts, so it's basically free. class _ToastHostRegistry { static final Map _map = {}; static _ToastHostController of(OverlayState overlay) { return _map.putIfAbsent(overlay, () { final controller = _ToastHostController(); final entry = OverlayEntry( builder: (_) => _ToastHostWidget(controller: controller), ); controller._overlayEntry = entry; overlay.insert(entry); return controller; }); } } class _ToastHostController extends ChangeNotifier { OverlayEntry? _overlayEntry; final List<_AttachedToast> toasts = []; ToastOverlay add(_ToastData data) { final attached = _AttachedToast(data, this); toasts.add(attached); notifyListeners(); return attached; } void remove(_AttachedToast toast) { if (toasts.remove(toast)) { notifyListeners(); } try { toast.data.onClosed?.call(); } catch (e, st) { // never swallow silently — a bad onClosed callback should still be seen. debugPrint("toast onClosed threw: $e\n$st"); } } } class _AttachedToast implements ToastOverlay { final GlobalKey<_ToastCardState> key = GlobalKey(); final _ToastData data; _ToastHostController? _host; _AttachedToast(this.data, this._host); @override bool get isShowing => _host != null; @override void close() { if (_host == null) return; // let the card play its exit anim, it calls back into remove() when done. key.currentState?.startClose(); _host = null; } } class _ToastHostWidget extends StatefulWidget { final _ToastHostController controller; const _ToastHostWidget({required this.controller}); @override State<_ToastHostWidget> createState() => _ToastHostWidgetState(); } class _ToastHostWidgetState extends State<_ToastHostWidget> { @override void initState() { super.initState(); widget.controller.addListener(_onChange); } @override void dispose() { widget.controller.removeListener(_onChange); super.dispose(); } void _onChange() { if (mounted) setState(() {}); } @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final scaling = theme.scaling; final containerPadding = theme.density.containerPadding; final containerGap = theme.density.containerGap; final padding = EdgeInsets.all(containerPadding * 1.5); final collapsedOffset = const Offset(0, 1.5) * containerGap; final toastConstraints = BoxConstraints.tightFor(width: 320 * scaling); final all = widget.controller.toasts; if (all.isEmpty) return const SizedBox.shrink(); final List layers = []; for (final location in ToastLocation.values) { final entries = all.where((t) => t.data.location == location).toList(); if (entries.isEmpty) continue; final List stacked = []; // newest first — index 0 sits at the aligned edge, older ones tuck behind. for (int i = entries.length - 1; i >= 0; i--) { final toast = entries[i]; final index = entries.length - 1 - i; stacked.add( _ToastCard( key: toast.key, attached: toast, index: index, collapsedOffset: collapsedOffset, constraints: toastConstraints, ), ); } layers.add( Positioned.fill( child: SafeArea( child: Padding( padding: padding, child: Align( alignment: location.alignment, child: ConstrainedBox( constraints: toastConstraints, child: Stack( alignment: location.alignment, clipBehavior: Clip.none, children: stacked, ), ), ), ), ), ), ); } return Stack( clipBehavior: Clip.none, fit: StackFit.passthrough, children: layers, ); } } class _ToastCard extends StatefulWidget { final _AttachedToast attached; final int index; final Offset collapsedOffset; final BoxConstraints constraints; const _ToastCard({ super.key, required this.attached, required this.index, required this.collapsedOffset, required this.constraints, }); @override State<_ToastCard> createState() => _ToastCardState(); } class _ToastCardState extends State<_ToastCard> with SingleTickerProviderStateMixin { late final AnimationController _anim; Timer? _dismissTimer; double _dragOffset = 0; // fraction of width, for swipe to dismiss bool _closing = false; double _closeDir = 0; // -1 / +1 when swiped away // captured when we start closing — _AttachedToast.close() nulls its _host so // we hold our own reference to actually remove ourselves once the exit ends. _ToastHostController? _closingHost; _ToastData get data => widget.attached.data; @override void initState() { super.initState(); _anim = AnimationController(vsync: this, duration: data.entryDuration); _anim.addStatusListener((status) { if (status == AnimationStatus.dismissed && _closing) { _closingHost?.remove(widget.attached); } }); _anim.forward(); _startTimer(); } void _startTimer() { _dismissTimer?.cancel(); _dismissTimer = Timer(data.showDuration, startClose); } void startClose() { if (_closing) return; _closing = true; _closingHost = widget.attached._host; _dismissTimer?.cancel(); if (mounted) { _anim.reverse(); } else { _closingHost?.remove(widget.attached); } } @override void dispose() { _dismissTimer?.cancel(); _anim.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final location = data.location; // entryAlignment is the opposite of the stacking direction — that's the // edge a fresh toast slides in from. final entryAlignment = location.childrenAlignment * -1; Widget content = ConstrainedBox( constraints: widget.constraints, child: Builder( builder: (context) => data.builder(context, widget.attached), ), ); content = data.themes.wrap(content); return AnimatedBuilder( animation: _anim, builder: (context, child) { final curved = data.curve.transform(_anim.value.clamp(0.0, 1.0)); final index = widget.index; // stacking: tuck older toasts behind with a small offset + scale. final stackOffset = Offset( widget.collapsedOffset.dx * location.childrenAlignment.x, widget.collapsedOffset.dy * location.childrenAlignment.y, ) * index.toDouble(); final scale = pow(0.9, index).toDouble(); // slide in from the entry edge, plus any swipe drag. var fractional = Offset( entryAlignment.x * (1.0 - curved) + _dragOffset + _closeDir, entryAlignment.y * (1.0 - curved), ); var opacity = curved; opacity *= 1 - (_dragOffset + _closeDir).abs(); opacity = opacity.clamp(0.0, 1.0); return Align( alignment: entryAlignment, child: Transform.translate( offset: stackOffset, child: FractionalTranslation( translation: fractional, child: Opacity( opacity: opacity, child: Transform.scale( scale: scale, alignment: entryAlignment, child: child, ), ), ), ), ); }, child: MouseRegion( hitTestBehavior: HitTestBehavior.deferToChild, onEnter: (_) => _dismissTimer?.cancel(), onExit: (_) { if (!_closing) _startTimer(); }, child: GestureDetector( // drag surface, not a control - keep it out of the // semantics tree so it doesn't read as a bogus scrollable excludeFromSemantics: true, onHorizontalDragStart: data.dismissible ? (_) { _dismissTimer?.cancel(); } : null, onHorizontalDragUpdate: data.dismissible ? (details) { final w = context.size?.width ?? 1; setState(() => _dragOffset += details.primaryDelta! / w); } : null, onHorizontalDragEnd: data.dismissible ? (_) { if (_dragOffset < -0.5) { _closeDir = -1.0; startClose(); } else if (_dragOffset > 0.5) { _closeDir = 1.0; startClose(); } else { setState(() => _dragOffset = 0); _startTimer(); } } : null, child: content, ), ), ); } } // --------------------------------------------------------------------------- // little layout helpers the dialog/toast UI leans on (shadcn used to provide // these off the barrel). // --------------------------------------------------------------------------- // interleave [separator] between the items of [items]. Iterable join(List items, Widget separator) sync* { for (var i = 0; i < items.length; i++) { if (i > 0) yield separator; yield items[i]; } } extension _GarageColumnGap on Column { Column gap(double g) => Column( key: key, mainAxisAlignment: mainAxisAlignment, mainAxisSize: mainAxisSize, crossAxisAlignment: crossAxisAlignment, textDirection: textDirection, verticalDirection: verticalDirection, textBaseline: textBaseline, children: join(children, SizedBox(height: g)).toList(), ); } extension _GarageRowGap on Row { Row gap(double g) => Row( key: key, mainAxisAlignment: mainAxisAlignment, mainAxisSize: mainAxisSize, crossAxisAlignment: crossAxisAlignment, textDirection: textDirection, verticalDirection: verticalDirection, textBaseline: textBaseline, children: join(children, SizedBox(width: g)).toList(), ); } extension _GarageOverlayIconExt on Widget { // NOT the public IconExtension.iconLarge - that one wraps in // _GarageWrappedIcon, this wraps in _IconStyled and carries a fallback the // public one lacks. Two same-named extensions on Widget meant which one you // got depended on import scope, so this is named apart. Widget get overlayIconLarge => _IconStyled( size: (t) => t.iconTheme.large.size ?? 32 * t.scaling, child: this, ); Widget iconMutedForeground() => _IconStyled(color: (t) => t.colorScheme.mutedForeground, child: this); // text colour helper (tooltip content). merges a colour over DefaultTextStyle. Widget primaryForeground() => Builder( builder: (context) => DefaultTextStyle.merge( style: TextStyle( color: GarageTheme.of(context).colorScheme.primaryForeground, ), child: this, ), ); } class _IconStyled extends StatelessWidget { const _IconStyled({this.size, this.color, required this.child}); final double Function(ThemeData)? size; final Color Function(ThemeData)? color; final Widget child; @override Widget build(BuildContext context) { final t = GarageTheme.of(context); return IconTheme.merge( data: IconThemeData(size: size?.call(t), color: color?.call(t)), child: child, ); } }