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
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
// 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user