Files
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

472 lines
17 KiB
Dart

// 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<ToastRequest> 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<CanvasToastLayer> createState() => _CanvasToastLayerState();
}
class _CanvasToastLayerState extends State<CanvasToastLayer> {
@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)),
),
],
),
);
}
}