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
472 lines
17 KiB
Dart
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)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|