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
1263 lines
41 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|