Files
Garage-SDKs/garage_ui/lib/overlay.dart
T
ImBenjiandClaude Opus 5.5 b269201919 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
2026-09-23 18:49:21 +01:00

1263 lines
41 KiB
Dart

// 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>? 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<Widget>? 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<AlertDialog> createState() => _AlertDialogState();
}
class _AlertDialogState extends State<AlertDialog> {
@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<double> animation,
bool fullScreen,
Widget child,
) {
final scaleTransition = ScaleTransition(
scale: CurvedAnimation(
parent: animation.drive(Tween<double>(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<T?> showDialog<T>({
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<T>(
RawDialogRoute<T>(
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<Tooltip> 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<Tooltip> 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<OverlayState, _ToastHostController> _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<Widget> layers = [];
for (final location in ToastLocation.values) {
final entries = all.where((t) => t.data.location == location).toList();
if (entries.isEmpty) continue;
final List<Widget> 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<Widget> join(List<Widget> 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,
);
}
}