// App-level toast entry point, built on top of overlay.dart's generic // showToast/ToastLocation/_ToastHost plumbing rather than replacing it. // // Two looks live side by side: // - compact/touch layouts keep the plain SurfaceCard+Basic toast from // before this file existed - bottom right of the WINDOW, no bar, no // close button, goes through showToast()'s own root-Overlay host. // - wide desktop layouts get the redesigned card below: severity icon, // a countdown bar along the bottom, an explicit close button - anchored // to the top right of the CANVAS specifically (not the window) by // [CanvasToastLayer], which sits inside the canvas's own Stack instead // of the root Overlay. that's a deliberate second, independent path // rather than reusing _ToastCardState's timer/hover state - there's no // clean way to reach into that private state from outside overlay.dart, // and duplicating "pause on hover, count down, close" is cheap next to // trying to thread a canvas rect through the root Overlay's coordinate // space instead. import "dart:async"; import "package:flutter/semantics.dart"; import "package:flutter/widgets.dart"; import "package:flutter_lucide/flutter_lucide.dart"; import "package:garage_ui/button.dart"; import "package:garage_ui/extensions.dart"; import "package:garage_ui/overlay.dart"; import "package:garage_ui/surface.dart"; import "package:garage_ui/theme/garage_theme.dart"; import "package:garage_ui/theme/support.dart"; enum ToastSeverity { info, success, error } class ToastRequest { final Object id; final String title; final String? subtitle; final ToastSeverity severity; // null = stays up until the user closes it - no timer, no drain bar. final Duration? showDuration; ToastRequest({ required this.id, required this.title, this.subtitle, required this.severity, required this.showDuration, }); } // plain singleton rather than something threaded through providers - toasts // are fire and forget from anywhere (editor actions, dialogs, panels) and // theres only ever one CanvasToastLayer listening at a time. class _DesktopToastQueue extends ChangeNotifier { _DesktopToastQueue._(); static final _DesktopToastQueue instance = _DesktopToastQueue._(); final List requests = []; void add(ToastRequest r) { requests.add(r); notifyListeners(); } void remove(Object id) { final before = requests.length; requests.removeWhere((r) => r.id == id); if (requests.length != before) notifyListeners(); } } /// Shows an app toast. [isMobile] lets a caller that already knows better /// (an editor variant flag, say) settle it explicitly; left null it falls /// back to a width check so a plain call still does the sensible thing. /// [showDuration] null means the toast stays up until closed by hand - the /// desktop card just skips the timer/bar entirely; the mobile toast's own /// showToast() primitive has no concept of "forever", so it gets a long but /// finite stand-in instead. void showAppToast({ required BuildContext context, required String title, String? subtitle, ToastSeverity severity = ToastSeverity.info, Duration? showDuration = const Duration(seconds: 5), bool? isMobile, }) { final mobile = isMobile ?? MediaQuery.sizeOf(context).width < 900; if (mobile) { showToast( context: context, location: ToastLocation.bottomRight, showDuration: showDuration ?? const Duration(days: 1), builder: (context, overlay) => SurfaceCard( child: Basic( title: Text(title), subtitle: subtitle == null ? null : Text(subtitle), ), ), ); return; } _DesktopToastQueue.instance.add( ToastRequest( id: UniqueKey(), title: title, subtitle: subtitle, severity: severity, showDuration: showDuration, ), ); } /// Sits inside the canvas's own Stack (not the root Overlay) so it anchors /// to the canvas's top-right corner rather than the window's. Self-hides /// when theres nothing queued, so its basically free to leave mounted. class CanvasToastLayer extends StatefulWidget { const CanvasToastLayer({super.key}); @override State createState() => _CanvasToastLayerState(); } class _CanvasToastLayerState extends State { @override void initState() { super.initState(); _DesktopToastQueue.instance.addListener(_onChange); } @override void dispose() { _DesktopToastQueue.instance.removeListener(_onChange); super.dispose(); } void _onChange() { if (mounted) setState(() {}); } @override Widget build(BuildContext context) { final requests = _DesktopToastQueue.instance.requests; if (requests.isEmpty) return const SizedBox.shrink(); final theme = GarageTheme.of(context); // containerPadding is a "form/dialog" spacing token - too generous for // sitting right at the canvas edge. containerGap reads much closer to // how other edge-pinned canvas chrome (HUD, controls overlay) sits. final pad = theme.density.containerGap; return Positioned( top: pad, right: pad, width: 300 * theme.scaling, child: AnimatedSize( duration: const Duration(milliseconds: 200), alignment: Alignment.topRight, curve: Curves.easeOut, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final r in requests) Padding( key: ValueKey(r.id), padding: EdgeInsets.only(bottom: theme.density.containerGap), child: _ToastCard( request: r, onDismiss: () => _DesktopToastQueue.instance.remove(r.id), ), ), ], ), ), ); } } /// The desktop toast card - severity icon, title/subtitle, close button, and /// a countdown bar along the bottom that drains as [ToastRequest.showDuration] /// elapses. Hovering pauses the countdown (bar freezes); leaving resumes it /// from wherever it left off, not a full reset. class _ToastCard extends StatefulWidget { const _ToastCard({required this.request, required this.onDismiss}); final ToastRequest request; final VoidCallback onDismiss; @override State<_ToastCard> createState() => _ToastCardState(); } class _ToastCardState extends State<_ToastCard> with TickerProviderStateMixin { late final AnimationController _entry; // null when the request has no showDuration - permanent, no countdown, no // bar, closes only via the X. AnimationController? _progress; bool _closing = false; @override void initState() { super.initState(); // A toast never takes focus, so nothing walks a screen reader onto it - // it has to be pushed. The liveRegion flag below covers the case where // the card is already mounted and its text changes; this covers the far // more common one, which is the card appearing at all. final r = widget.request; SemanticsService.announce( r.subtitle == null ? r.title : "${r.title}. ${r.subtitle}", // toasts sit top-right in LTR; the direction only steers where the // announcement is attributed, not what gets read. TextDirection.ltr, assertiveness: r.severity == ToastSeverity.error ? Assertiveness.assertive : Assertiveness.polite, ); _entry = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), )..forward(); final duration = widget.request.showDuration; if (duration != null) { final progress = AnimationController(vsync: this, duration: duration); progress.addStatusListener(_onProgressStatus); progress.forward(); _progress = progress; } } void _onProgressStatus(AnimationStatus status) { if (status == AnimationStatus.completed) _startClose(); } void _startClose() { if (_closing) return; _closing = true; _progress?.stop(); unawaited( _entry.reverse().whenComplete(() { if (mounted) widget.onDismiss(); }), ); } void _pause() { if (!_closing) _progress?.stop(); } void _resume() { if (!_closing && _progress?.isAnimating == false) _progress!.forward(); } @override void dispose() { _entry.dispose(); _progress?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final scheme = theme.colorScheme; final IconData icon; final Color accent; switch (widget.request.severity) { case ToastSeverity.success: icon = LucideIcons.circle_check; accent = scheme.chart2; break; case ToastSeverity.error: icon = LucideIcons.circle_alert; accent = scheme.destructive; break; case ToastSeverity.info: icon = LucideIcons.info; accent = scheme.primary; break; } return AnimatedBuilder( animation: _entry, builder: (context, child) { final t = Curves.easeOutCubic.transform(_entry.value.clamp(0.0, 1.0)); return Opacity( opacity: t, child: Transform.translate( offset: Offset((1 - t) * 24, 0), child: child, ), ); }, child: Semantics( liveRegion: true, container: true, child: MouseRegion( onEnter: (_) => _pause(), onExit: (_) => _resume(), child: SurfaceCard( padding: EdgeInsets.zero, clipBehavior: Clip.antiAlias, // same fill/border pair AlertDialog uses (ModalContainer) rather // than the plain card colours - a toast is a floating overlay like // a dialog, not an in-canvas panel, so it reads better matching that. filled: true, fillColor: scheme.popover, borderColor: scheme.popoverBorder, borderWidth: 1 * theme.scaling, // xxl matches the dialog's colours but was too pillowy for a // toast this short - md keeps the family resemblance without it. borderRadius: theme.borderRadiusMd, // Column, not Stack+Positioned - a Positioned bar doesn't grow the // Stack's own size, it just overlays wherever "bottom: 0" lands // WITHIN whatever height the text already claimed. for a long, // wrapped subtitle that's directly behind the last line of text - // invisible. a real Column child always gets its own reserved // strip below the content, however tall that content gets. child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Mirrors AlertDialog's own Row/Column, not Basic's generic // list-tile styling - same icon treatment (iconLarge), and // title/subtitle set to the SAME text style, distinguished by // colour only (full foreground vs muted) rather than size or // weight, same as the dialog's title/content pairing. Padding( padding: EdgeInsets.all(theme.density.containerGap), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // same border/fill/radius formula as ButtonVariance.outline // (_buttonOutlineDecoration) - reads as a matching control // rather than a bespoke badge. icon stays accent-coloured // (not the outline button's usual mutedForeground) since // that colour is the whole point of a severity icon. // no * scaling here either - every explicit Icon(size:) // literal elsewhere in the app (explorer rows included) // is a flat number, only the iconSmall/Medium/Large // extensions multiply by scaling. Container( width: 32, height: 32, decoration: BoxDecoration( color: const Color(0x00000000), border: Border.all( color: scheme.controlBorder, width: theme.density.controlBorderWidth, ), borderRadius: theme.borderRadiusMd, ), child: Center( child: Icon( icon, size: 18, color: const Color(0xFFFFFFFF), ), ), ), SizedBox(width: theme.density.containerGap), Expanded( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // no fontSize at all, same as the explorer panel's // _RootRow label - both inherit GarageTheme's own // root DefaultTextStyle (11 * scaling) instead of a // second hand-typed number that can drift from it. // fontWeight w400 matches that same row's override; // colour is the only thing telling title/subtitle // apart. Text( widget.request.title, style: TextStyle( fontWeight: FontWeight.w400, color: scheme.foreground, ), ), if (widget.request.subtitle case final subtitle?) ...[ SizedBox( height: theme.density.containerGap * 0.375, ), Text( subtitle, style: TextStyle( fontWeight: FontWeight.w400, color: scheme.mutedForeground, ), ), ], ], ), ), SizedBox(width: theme.density.containerGap), IconButton.outline( icon: const Icon(LucideIcons.x).iconSmall, onPressed: _startClose, ), ], ), ), if (_progress case final progress?) AnimatedBuilder( animation: progress, builder: (context, _) => _ToastDrainBar( remainingFraction: 1 - progress.value, color: accent, trackColor: scheme.popoverBorder, thickness: 3, ), ), ], ), ), ), ), ); } } class _ToastDrainBar extends StatelessWidget { const _ToastDrainBar({ required this.remainingFraction, required this.color, required this.trackColor, required this.thickness, }); final double remainingFraction; final Color color; final Color trackColor; final double thickness; @override Widget build(BuildContext context) { // Row/Expanded rather than Stack + Align + FractionallySizedBox. the // latter renders nothing here: an unparented ColoredBox has no intrinsic // size, so once FractionallySizedBox hands it a loose constraint it // collapses to zero width and the whole bar disappears. Expanded's flex // is resolved against the Row's own width, so each half always gets a // real tight constraint no matter how the bar is nested. final fill = (remainingFraction.clamp(0.0, 1.0) * 1000).round(); final rest = 1000 - fill; return SizedBox( height: thickness, child: Row( // stretch is load-bearing, NOT cosmetic. a ColoredBox with no child // collapses to the smallest size its constraints allow, and Row's // default (center) hands children a LOOSE height - so both halves // sized to height 0 and the bar vanished while still "building" // perfectly happily. stretch makes that height tight. crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (fill > 0) Expanded( flex: fill, child: ColoredBox(color: color), ), if (rest > 0) Expanded( flex: rest, child: ColoredBox(color: trackColor.scaleAlpha(0.4)), ), ], ), ); } }