diff --git a/AGENTS.md b/AGENTS.md
index 87b1059..6d5e334 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -98,3 +98,4 @@ Status legend: ⬜ Todo · ✅ Done
| 26 | Chat View | centred 760 rail; zero state (greeting, lifted composer, starters); jump to latest | ✅ |
| 27 | SidePanel | | ⬜ |
| 28 | Modal | | ⬜ |
+| 29 | Toast | floating notice: icon, message, cross on a frosted card; showFlowToast floats it in the nearest Overlay, stacked, auto-dismissing, hover-paused, with a handle to dismiss | ✅ |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16eb3f2..e226291 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,18 @@
## 0.3.0 (unreleased)
+- **Toast** — `FlowToast`, the floating notice: a host-supplied glyph,
+ one line that wraps and a cross on the raised card at 80%, the line
+ and the cross's label host-localized. `showFlowToast` floats it in
+ the nearest `Overlay` with no setup: 358 wide in the top end corner
+ on wide layouts, the full width inside 16 on compact ones, over a 3px
+ frost of the page; toasts stack three deep as a deck, the newest in front and the rest peeking out behind it, fanning out under the pointer,
+ leave on their own after four seconds (or never, with
+ `duration: null`), pause under the pointer, and hand back a
+ `FlowToastHandle` to dismiss one early or await its closing.
+ `FlowToastStyle` joins the component styles with a
+ `FlowTheme.toastStyle` default; the example shows one for a copied
+ message.
- **Selectable text** — text in a `FlowThread` is selectable, the way a
chat in a browser is: drag across turns with a mouse, long-press on
touch, copy with the platform's shortcut or menu. A copy keeps its line
diff --git a/CLAUDE.md b/CLAUDE.md
index cbf7b93..82753fd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -100,3 +100,4 @@ Values come from the Flow UI Figma file. Role names follow Material 3's `ColorSc
| 26 | Chat View | centred 760 rail; zero state (greeting, lifted composer, starters); jump to latest | ✅ |
| 27 | SidePanel | | ⬜ |
| 28 | Modal | | ⬜ |
+| 29 | Toast | floating notice: icon, message, cross on a frosted card; showFlowToast floats it in the nearest Overlay, stacked, auto-dismissing, hover-paused, with a handle to dismiss | ✅ |
diff --git a/README.md b/README.md
index 46ef015..8a46088 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,7 @@
| [`FlowMarkdown`](https://flowui.stac.dev/components/markdown) | Assistant prose typeset from a built-in parser — headings, emphasis, lists, quotes, tables, links, and fences composing the code block; assistant turns render it by default and it streams gracefully |
| [`FlowErrorState`](https://flowui.stac.dev/components/error-state) | Failure card with a host-written message and retry pill — failed turns render it automatically |
| [`FlowConfirmation`](https://flowui.stac.dev/components/confirmation) | Approval card — an asterisk-marked request with approve and reject buttons that settles into the outcome; confirmation parts render it in a thread |
+| [`FlowToast`](https://flowui.stac.dev/components/toast) | Floating notice: a glyph, one wrapping line and a cross on a frosted card; `showFlowToast` floats it in the nearest Overlay, stacked and auto-dismissing, with a handle to dismiss it early |
| [`FlowMessageActions`](https://flowui.stac.dev/components/message-actions) | Copy / regenerate / edit / feedback row under a message |
| [`FlowComposer`](https://flowui.stac.dev/components/composer) | Multiline input with send/stop, attachments strip, the platform's file dialog (`showFlowAttachmentPicker` from your own menu, or a built-in attach button), image paste and card-scoped drop (web), and leading/trailing action slots |
| [`FlowMenu`](https://flowui.stac.dev/components/menu) | Icon-triggered menu with groups, submenus, and toggles — anchored card on desktop, bottom sheet on phones |
diff --git a/docs/public/_redirects b/docs/public/_redirects
index a083110..a23f6ee 100644
--- a/docs/public/_redirects
+++ b/docs/public/_redirects
@@ -27,6 +27,7 @@
/playground/markdown /playground/ 200
/playground/error-state /playground/ 200
/playground/confirmation /playground/ 200
+/playground/toast /playground/ 200
/playground/add-to-chat /playground/ 200
/playground/pill /playground/ 200
/playground/attachments /playground/ 200
diff --git a/docs/src/content/docs/components/toast.mdx b/docs/src/content/docs/components/toast.mdx
new file mode 100644
index 0000000..86e2266
--- /dev/null
+++ b/docs/src/content/docs/components/toast.mdx
@@ -0,0 +1,163 @@
+---
+title: Toast
+description: "The floating notice: a glyph, one line and a cross on a frosted card. showFlowToast floats it in the nearest Overlay, stacks it, and takes it down on its own."
+sidebar:
+ order: 19
+---
+
+import FlowDemo from '../../../components/FlowDemo.astro';
+
+`FlowToast` is the floating notice: a glyph, one line that wraps, and a
+cross on the raised card. "Message copied to clipboard." "Image upload
+failed. Try again." The card renders state and reports one intent,
+dismiss. The package ships no strings, so the line and the cross's label
+are host-localized, and the line announces to assistive tech as a live
+region, since notices arrive unprompted.
+
+What a *toast* adds to the card is a lifecycle: where it floats, how long
+it stays, what happens when three arrive at once. Nobody wants to own
+that for a two-line notice, so `showFlowToast` owns it, the way
+`showFlowAttachmentPreview` owns its route: no setup, one call, and a
+handle to take it back. Hosts that want the lifecycle keep the card and
+place it themselves.
+
+## Showing one
+
+One call floats the card in the nearest `Overlay` (a `MaterialApp` or a
+`Navigator` has one) and it leaves on its own after four seconds, or at
+once from its cross. The glyph is the host's, and carries the meaning:
+
+
+
+```dart title="The whole of it"
+showFlowToast(
+ context: context,
+ icon: Icons.copy_outlined,
+ message: 'Message copied to clipboard',
+ dismissTooltip: 'Dismiss',
+)
+```
+
+A failure is the same call with a different glyph, in the error accent.
+The line stays in the ink ramp whatever the glyph says: the light accents
+fall short of WCAG AA for text on the card, the confirmation card's rule.
+
+
+
+```dart title="The failure form"
+showFlowToast(
+ context: context,
+ icon: Icons.error_outline,
+ message: 'Image upload failed. Try again',
+ dismissTooltip: 'Dismiss',
+ style: FlowToastStyle(iconColor: context.flowColors.error),
+)
+```
+
+## Staying, and the handle
+
+`duration` is how long the toast stays; null keeps it up until it is
+dismissed, for a notice that tracks work in progress. Every call hands
+back a `FlowToastHandle`: `dismiss()` takes the toast down early,
+`closed` completes once it has left, and `isShowing` says whether it is
+still up. Hovering the deck pauses every clock, so a line that is being
+read does not vanish mid-sentence.
+
+
+
+```dart title="Holding a toast through the work"
+final handle = showFlowToast(
+ context: context,
+ icon: Icons.upload_outlined,
+ message: 'Uploading 3 files…',
+ duration: null,
+ dismissTooltip: 'Dismiss',
+);
+await upload(files);
+handle.dismiss();
+```
+
+## Several at once
+
+Toasts stack as a deck: the newest in front, the ones behind it peeking
+out above, each a step smaller, three at most. A fourth dismisses the
+oldest. Hovering the deck fans it out, so every line can be read and
+every cross reached, and pauses every clock while the pointer stays;
+under assistive navigation it stays fanned out. Touch has no hover, so
+there the front card is the one to dismiss, and the next surfaces as it
+leaves.
+
+
+
+## Where it floats
+
+Placement is read from the overlay's own width, at the chat view's
+compact boundary. From 600 wide the card sits 358 across, 24 in from the
+top end corner; below that it spans the width inside 16 from the top
+edge. Both measure from the display's edge and absorb its own insets, so
+a notch or home indicator adds nothing on top of them.
+
+The overlay is the nearest one above the `context`, so a toast raised
+inside a nested `Navigator` stays inside it, and an `Overlay.wrap` around
+a pane keeps its toasts in the pane (the demos on this page do exactly
+that); `rootOverlay: true` reaches the app's overlay instead, as
+`Overlay.of` does. Toasts float above every route, dialogs and sheets
+included. Call it from a handler, not from a build: raising the layer
+rebuilds the overlay, the same rule as pushing a route.
+
+Under assistive navigation no toast leaves on its own, the snack bar's
+rule: a notice gone before it was reached was never shown. Keyboard focus
+stays where it was (a toast never steals it), so bind Escape to the
+handle's `dismiss()` where a keyboard should clear one.
+
+## The card on its own
+
+`FlowToast` is the card alone, for hosts that own the lifecycle: state
+in, one intent out. It carries no frost of its own. The 3px blur of the
+page belongs to the layer that floats it, because a blur inside the card
+would sit inside its fade, and a `BackdropFilter` under an `Opacity`
+samples the fade's own layer rather than the page.
+
+
+
+```dart title="Placing the card yourself"
+FlowToast(
+ icon: Icons.copy_outlined,
+ message: 'Message copied to clipboard',
+ dismissTooltip: 'Dismiss',
+ onDismiss: hide,
+)
+```
+
+## Restyling
+
+`FlowToastStyle` carries the card's overrides. Install one on
+`FlowTheme.toastStyle` for every toast, the floated ones included, or
+pass `style:` to one call or one widget; a widget's own style wins field
+by field, and nulls fall through to the tokens. The fill defaults to
+`surfaceBright` at 80%, the hairline to `outlineVariant`, the glyph to
+`onSurface` and the cross to `onSurfaceVariant`; `messageStyle` merges
+over the line's `labelMediumEmphasised`:
+
+```dart title="A success glyph"
+showFlowToast(
+ context: context,
+ icon: Icons.check_circle_outline,
+ message: '3 files uploaded',
+ style: FlowToastStyle(iconColor: context.flowColors.success),
+)
+```
+
+Beyond the style class, `padding:` and `borderRadius:` override the
+card's own metrics, the per-component convention.
+
+## Key API
+
+| Member | What it does |
+|---|---|
+| `message` | Host-localized line; wraps when long, announced as a live region |
+| `icon` | The leading glyph; null draws none |
+| `onDismiss` / `dismissTooltip` | The cross's intent and label; the card draws no cross without a callback |
+| `showFlowToast` | Floats the card in the nearest `Overlay`: `duration` (4s; null stays), `rootOverlay`, and a `FlowToastHandle` back |
+| `FlowToastHandle` | `dismiss()`, `closed`, `isShowing` |
+| `style` | `FlowToastStyle` overrides, merged over `FlowTheme.toastStyle` |
diff --git a/docs/src/content/docs/roadmap.md b/docs/src/content/docs/roadmap.md
index 58894b6..fe5ebbb 100644
--- a/docs/src/content/docs/roadmap.md
+++ b/docs/src/content/docs/roadmap.md
@@ -53,3 +53,4 @@ elements and the remaining AI states are on the way.
| Chat view | Shipped |
| Side panel | Planned |
| Modal | Planned |
+| Toast | Shipped |
diff --git a/docs/src/content/docs/theming.mdx b/docs/src/content/docs/theming.mdx
index 439530f..d346e7d 100644
--- a/docs/src/content/docs/theming.mdx
+++ b/docs/src/content/docs/theming.mdx
@@ -138,7 +138,7 @@ data bag of optional overrides (`FlowMenuStyle`, `FlowMarkdownStyle`,
`FlowComposerStyle`, `FlowMessageStyle`, `FlowCodeBlockStyle`,
`FlowConfirmationStyle`, `FlowErrorStateStyle`,
`FlowMessageActionsStyle`, `FlowPillStyle`,
-`FlowSuggestionStyle`), and the theme can carry an app-wide default for
+`FlowSuggestionStyle`, `FlowToastStyle`), and the theme can carry an app-wide default for
each:
```dart title="Restyle every instance once"
diff --git a/example/lib/main.dart b/example/lib/main.dart
index 0bb14a2..47e340c 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -381,9 +381,12 @@ class _ChatScreenState extends State {
if (part is FlowTextPart) part.text,
].join('\n');
Clipboard.setData(ClipboardData(text: text));
- ScaffoldMessenger.of(
- context,
- ).showSnackBar(const SnackBar(content: Text('Copied')));
+ showFlowToast(
+ context: context,
+ icon: Icons.copy_outlined,
+ message: 'Message copied to clipboard',
+ dismissTooltip: 'Dismiss',
+ );
}
/// The actions row under a settled assistant reply: copy, feedback, and
diff --git a/lib/flow_ui.dart b/lib/flow_ui.dart
index 4104faa..a30a86c 100644
--- a/lib/flow_ui.dart
+++ b/lib/flow_ui.dart
@@ -55,3 +55,6 @@ export 'src/widgets/flow_thinking_indicator.dart';
export 'src/widgets/flow_thread.dart';
export 'src/widgets/flow_thread_list.dart';
export 'src/styles/flow_thread_list_style.dart';
+export 'src/widgets/flow_toast.dart';
+export 'src/styles/flow_toast_style.dart';
+export 'src/utils/flow_toast_layer.dart' show showFlowToast, FlowToastHandle;
diff --git a/lib/src/styles/flow_toast_style.dart b/lib/src/styles/flow_toast_style.dart
new file mode 100644
index 0000000..e1be15f
--- /dev/null
+++ b/lib/src/styles/flow_toast_style.dart
@@ -0,0 +1,90 @@
+import 'package:material_ui/material_ui.dart';
+
+/// Host overrides for [FlowToast]'s look, on top of the theme tokens.
+///
+/// Every field is optional; null falls back to the token-derived default
+/// noted on the field. Install one on [FlowTheme.toastStyle] to restyle
+/// every toast — the ones `showFlowToast` floats included; a widget's own
+/// `style` wins field by field. The glyph is where a toast carries its
+/// meaning, so the failure form is one override:
+///
+/// ```dart
+/// showFlowToast(
+/// context: context,
+/// icon: Icons.error_outline,
+/// message: 'Image upload failed. Try again',
+/// style: FlowToastStyle(iconColor: context.flowColors.error),
+/// )
+/// ```
+@immutable
+class FlowToastStyle {
+ const FlowToastStyle({
+ this.backgroundColor,
+ this.borderColor,
+ this.iconColor,
+ this.dismissIconColor,
+ this.messageStyle,
+ });
+
+ /// The card's fill. Defaults to `surfaceBright` at 80%.
+ final Color? backgroundColor;
+
+ /// The card's hairline. Defaults to `outlineVariant`.
+ final Color? borderColor;
+
+ /// The leading glyph. Defaults to `onSurface`; a failure toast passes
+ /// `error`, a success one `success`.
+ final Color? iconColor;
+
+ /// The dismiss cross. Defaults to `onSurfaceVariant`.
+ final Color? dismissIconColor;
+
+ /// Merged over the message's default `labelMediumEmphasised` +
+ /// `onSurface` style.
+ final TextStyle? messageStyle;
+
+ /// A copy where [other]'s fields win over this style's.
+ FlowToastStyle merge(FlowToastStyle? other) {
+ if (other == null) return this;
+ return FlowToastStyle(
+ backgroundColor: other.backgroundColor ?? backgroundColor,
+ borderColor: other.borderColor ?? borderColor,
+ iconColor: other.iconColor ?? iconColor,
+ dismissIconColor: other.dismissIconColor ?? dismissIconColor,
+ messageStyle: other.messageStyle ?? messageStyle,
+ );
+ }
+
+ /// Linear interpolation, for theme transitions. A null [other] returns
+ /// this style unchanged.
+ FlowToastStyle lerp(FlowToastStyle? other, double t) {
+ if (other == null) return this;
+ return FlowToastStyle(
+ backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t),
+ borderColor: Color.lerp(borderColor, other.borderColor, t),
+ iconColor: Color.lerp(iconColor, other.iconColor, t),
+ dismissIconColor: Color.lerp(dismissIconColor, other.dismissIconColor, t),
+ messageStyle: TextStyle.lerp(messageStyle, other.messageStyle, t),
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ return other is FlowToastStyle &&
+ other.backgroundColor == backgroundColor &&
+ other.borderColor == borderColor &&
+ other.iconColor == iconColor &&
+ other.dismissIconColor == dismissIconColor &&
+ other.messageStyle == messageStyle;
+ }
+
+ @override
+ int get hashCode => Object.hash(
+ backgroundColor,
+ borderColor,
+ iconColor,
+ dismissIconColor,
+ messageStyle,
+ );
+}
diff --git a/lib/src/theme/flow_theme.dart b/lib/src/theme/flow_theme.dart
index 2c3dbd3..51f8238 100644
--- a/lib/src/theme/flow_theme.dart
+++ b/lib/src/theme/flow_theme.dart
@@ -12,6 +12,7 @@ import '../styles/flow_message_style.dart';
import '../styles/flow_pill_style.dart';
import '../styles/flow_suggestion_style.dart';
import '../styles/flow_thread_list_style.dart';
+import '../styles/flow_toast_style.dart';
import 'flow_colors.dart';
import 'flow_syntax_colors.dart';
import 'flow_typography.dart';
@@ -58,6 +59,7 @@ class FlowTheme extends ThemeExtension {
this.pillStyle,
this.suggestionStyle,
this.threadListStyle,
+ this.toastStyle,
this.chatViewStyle,
});
@@ -117,6 +119,10 @@ class FlowTheme extends ThemeExtension {
/// App-wide default for every `FlowThreadList`.
final FlowThreadListStyle? threadListStyle;
+ /// App-wide default for every `FlowToast` — the ones `showFlowToast`
+ /// floats included.
+ final FlowToastStyle? toastStyle;
+
/// App-wide default for `FlowChatView.style` — the drop treatment's
/// gradient, glyph and label.
final FlowChatViewStyle? chatViewStyle;
@@ -137,6 +143,7 @@ class FlowTheme extends ThemeExtension {
FlowPillStyle? pillStyle,
FlowSuggestionStyle? suggestionStyle,
FlowThreadListStyle? threadListStyle,
+ FlowToastStyle? toastStyle,
FlowChatViewStyle? chatViewStyle,
}) {
return FlowTheme(
@@ -154,6 +161,7 @@ class FlowTheme extends ThemeExtension {
pillStyle: pillStyle ?? this.pillStyle,
suggestionStyle: suggestionStyle ?? this.suggestionStyle,
threadListStyle: threadListStyle ?? this.threadListStyle,
+ toastStyle: toastStyle ?? this.toastStyle,
chatViewStyle: chatViewStyle ?? this.chatViewStyle,
);
}
@@ -198,6 +206,9 @@ class FlowTheme extends ThemeExtension {
threadListStyle: threadListStyle == null
? other.threadListStyle
: threadListStyle!.lerp(other.threadListStyle, t),
+ toastStyle: toastStyle == null
+ ? other.toastStyle
+ : toastStyle!.lerp(other.toastStyle, t),
chatViewStyle: chatViewStyle == null
? other.chatViewStyle
: chatViewStyle!.lerp(other.chatViewStyle, t),
diff --git a/lib/src/utils/flow_toast_layer.dart b/lib/src/utils/flow_toast_layer.dart
new file mode 100644
index 0000000..79a04e2
--- /dev/null
+++ b/lib/src/utils/flow_toast_layer.dart
@@ -0,0 +1,804 @@
+import 'dart:async';
+import 'dart:math' as math;
+import 'dart:ui' show ImageFilter, lerpDouble;
+
+import 'package:flutter/rendering.dart' show RenderProxyBox;
+import 'package:material_ui/material_ui.dart';
+
+import '../styles/flow_toast_style.dart';
+import '../widgets/flow_toast.dart';
+
+// The toast layer: one OverlayEntry per Overlay, raised by the first toast
+// and dropped once the last one has left. Only showFlowToast and
+// FlowToastHandle are exported from the package barrel.
+
+/// The frost under every toast: the design's 3px blur of the page. One
+/// filter for all of them — a [BackdropFilter] repaints the whole viewport
+/// whenever its filter changes, the preview's note.
+final ImageFilter _blurFilter = ImageFilter.blur(sigmaX: 3, sigmaY: 3);
+
+/// Floats a [FlowToast] in the nearest [Overlay] and hands back its handle.
+///
+/// ```dart
+/// showFlowToast(
+/// context: context,
+/// icon: Icons.copy_outlined,
+/// message: 'Message copied to clipboard',
+/// dismissTooltip: 'Dismiss',
+/// )
+/// ```
+///
+/// No setup: the layer raises itself in the overlay above [context] — a
+/// `MaterialApp`, a `Navigator`, or an `Overlay` of the host's own, the
+/// nearest one, so an `Overlay.wrap` around a pane keeps its toasts inside
+/// it; [rootOverlay] reaches the app's instead, as `Overlay.of` does — and
+/// leaves again once the last toast has gone. Toasts sit above every
+/// route, dialogs and sheets included.
+///
+/// Where it floats is read from the overlay's own width: 358 wide, 24 in
+/// from the top end corner, on layouts 600 and wider; the full width inside
+/// 16 from the top edge below that. Both distances measure from the
+/// display's edge and absorb its own insets — a notch pushes the card only
+/// as far as it exceeds them.
+///
+/// Toasts stack as a deck: the newest in front, the ones behind it peeking
+/// out above, each a step smaller, three at most; a fourth dismisses the
+/// oldest. Under the pointer the deck fans out, so every line can be read
+/// and every cross reached, and it stays fanned out under assistive
+/// navigation.
+///
+/// A toast leaves on its own after [duration] — four seconds unless told
+/// otherwise, every clock paused while the pointer is over the deck — or
+/// at once
+/// from its cross or [FlowToastHandle.dismiss]. Null keeps it up until
+/// dismissed, for a notice that tracks work in progress. Under assistive
+/// navigation no toast leaves on its own, the snack bar's rule: a notice
+/// gone before it was reached was never shown.
+///
+/// Call it from a handler, not from a build: raising the layer rebuilds
+/// the overlay, which a build in progress forbids — the same rule as
+/// pushing a route.
+FlowToastHandle showFlowToast({
+ required BuildContext context,
+ required String message,
+ IconData? icon,
+ Duration? duration = const Duration(seconds: 4),
+ String? dismissTooltip,
+ bool rootOverlay = false,
+ FlowToastStyle? style,
+}) {
+ assert(
+ duration == null || duration > Duration.zero,
+ 'duration must be positive, or null for a toast that stays until '
+ 'dismissed',
+ );
+ final toast = FlowToast(
+ message: message,
+ icon: icon,
+ dismissTooltip: dismissTooltip,
+ style: style,
+ );
+ // The framework's own error names the widget: "No Overlay widget found.
+ // FlowToast widgets require an Overlay widget ancestor…".
+ final overlay = Overlay.of(
+ context,
+ rootOverlay: rootOverlay,
+ debugRequiredFor: toast,
+ );
+ return _FlowToastStack.of(overlay).show(toast, duration: duration);
+}
+
+/// A toast [showFlowToast] floated: dismiss it early, or wait for it to go.
+class FlowToastHandle {
+ FlowToastHandle._(this._record);
+
+ final _FlowToastRecord _record;
+
+ /// Starts the exit. A no-op once the toast has left.
+ void dismiss() => _record.dismiss();
+
+ /// Completes once the toast has left the tree — dismissed, timed out,
+ /// pushed off by newer ones, or its overlay torn down.
+ Future get closed => _record.closed.future;
+
+ /// True until [closed] completes, the exit animation included.
+ bool get isShowing => !_record.closed.isCompleted;
+}
+
+/// One floated toast: what to draw, how long it stays, and where it is in
+/// its life.
+class _FlowToastRecord {
+ _FlowToastRecord(this.toast, {required this.duration});
+
+ final FlowToast toast;
+ final Duration? duration;
+ final Completer closed = Completer();
+
+ /// Installed by the item once it is on screen; the handle's dismiss
+ /// lands here. A dismiss before that is remembered and honoured on
+ /// mount.
+ VoidCallback? leave;
+ bool dismissed = false;
+
+ /// Its exit is running: it holds its place in the deck and counts for
+ /// no card behind it.
+ bool leaving = false;
+
+ void dismiss() {
+ if (dismissed || closed.isCompleted) return;
+ dismissed = true;
+ leave?.call();
+ }
+
+ void close() {
+ if (!closed.isCompleted) closed.complete();
+ }
+}
+
+/// The toasts over one [Overlay], and the entry that draws them.
+class _FlowToastStack extends ChangeNotifier {
+ _FlowToastStack._(this.overlay);
+
+ /// Three at most; a fourth pushes the oldest off.
+ static const int _maxVisible = 3;
+
+ /// Live stacks by overlay. Statics survive a hot reload and so do the
+ /// [OverlayState]s keying them, so the map stays valid across one; a hot
+ /// restart starts both over.
+ static final Map _stacks = {};
+
+ static _FlowToastStack of(OverlayState overlay) =>
+ _stacks.putIfAbsent(overlay, () => _FlowToastStack._(overlay).._mount());
+
+ final OverlayState overlay;
+
+ /// Newest first — the order they stack in, nearest the edge.
+ final List<_FlowToastRecord> toasts = [];
+
+ late final OverlayEntry _entry = OverlayEntry(
+ builder: (_) => _FlowToastLayer(stack: this),
+ );
+ bool _released = false;
+
+ void _mount() => overlay.insert(_entry);
+
+ FlowToastHandle show(FlowToast toast, {required Duration? duration}) {
+ final record = _FlowToastRecord(toast, duration: duration);
+ toasts.insert(0, record);
+ for (final old in toasts.skip(_maxVisible)) {
+ old.dismiss();
+ }
+ notifyListeners();
+ return FlowToastHandle._(record);
+ }
+
+ /// From an item as its exit begins: the deck moves the cards behind
+ /// up at once, not once the card has gone.
+ void leaving(_FlowToastRecord record) {
+ record.leaving = true;
+ notifyListeners();
+ }
+
+ /// From an item, once its exit has run.
+ void remove(_FlowToastRecord record) {
+ if (!toasts.remove(record)) return;
+ record.close();
+ if (toasts.isNotEmpty) {
+ notifyListeners();
+ return;
+ }
+ // The last one left: the entry goes now, and the layer's dispose —
+ // next frame, once the overlay has rebuilt without it — drops the
+ // notifier. remove() then dispose() back to back is the SDK's own
+ // idiom.
+ _release();
+ _entry
+ ..remove()
+ ..dispose();
+ }
+
+ void _release() {
+ _released = true;
+ _stacks.remove(overlay);
+ }
+
+ /// The layer unmounted. After a release that is the tail of the normal
+ /// path. Otherwise the overlay is going away under live toasts — its
+ /// route popped, the playground remounting its phone, a hot restart —
+ /// and this runs inside its unmount cascade, while `overlay.mounted`
+ /// still reads true: the entry is let go a microtask later, when
+ /// remove() on a dead overlay is the documented no-op.
+ void layerUnmounted() {
+ if (_released) {
+ dispose();
+ return;
+ }
+ _release();
+ for (final record in toasts) {
+ record.close();
+ }
+ toasts.clear();
+ scheduleMicrotask(() {
+ _entry
+ ..remove()
+ ..dispose();
+ dispose();
+ });
+ }
+}
+
+/// The entry's widget: the stack's toasts as a deck at the overlay's top
+/// edge, placed by the overlay's own width.
+class _FlowToastLayer extends StatefulWidget {
+ const _FlowToastLayer({required this.stack});
+
+ final _FlowToastStack stack;
+
+ @override
+ State<_FlowToastLayer> createState() => _FlowToastLayerState();
+}
+
+/// Room kept around a card's clip for its shadow: the 24 blur reaches a
+/// little past its own radius.
+const double _shadowSlack = 36;
+
+/// Taller than any card: an uncovered card whose height is not yet known
+/// is clipped this far down, and the clipper stops at the card's own edge
+/// anyway.
+const double _anyCard = 400;
+
+/// Where a card sits in the deck: how far down from the deck's top, how
+/// much smaller than the front card, and where its clip ends — the foot
+/// of the strip that peeks out above the card in front, or the shadow's
+/// slack below an uncovered card.
+@immutable
+class _Pose {
+ const _Pose({
+ required this.dy,
+ required this.scale,
+ required this.clipBottom,
+ });
+
+ /// Where a card starts before the deck has placed it.
+ static const _Pose open = _Pose(
+ dy: 0,
+ scale: 1,
+ clipBottom: _anyCard + _shadowSlack,
+ );
+
+ final double dy;
+ final double scale;
+ final double clipBottom;
+
+ static _Pose lerp(_Pose a, _Pose b, double t) => _Pose(
+ dy: lerpDouble(a.dy, b.dy, t)!,
+ scale: lerpDouble(a.scale, b.scale, t)!,
+ clipBottom: lerpDouble(a.clipBottom, b.clipBottom, t)!,
+ );
+
+ @override
+ bool operator ==(Object other) =>
+ other is _Pose &&
+ other.dy == dy &&
+ other.scale == scale &&
+ other.clipBottom == clipBottom;
+
+ @override
+ int get hashCode => Object.hash(dy, scale, clipBottom);
+}
+
+class _PoseTween extends Tween<_Pose> {
+ _PoseTween({super.end});
+
+ @override
+ _Pose lerp(double t) => _Pose.lerp(begin!, end!, t);
+}
+
+class _FlowToastLayerState extends State<_FlowToastLayer> {
+ /// Compact begins below 600 — the chat view's boundary, read from the
+ /// overlay's own constraints so a pane or a phone frame counts. Compact
+ /// spans the width inside 16 from the top edge; wide sits 358 across,
+ /// 24 in from the top end corner.
+ static const double _compactBreakpoint = 600;
+ static const double _compactInset = 16;
+ static const double _wideInset = 24;
+ static const double _wideWidth = 358;
+
+ /// The deck: the oldest card at the top edge, every newer one 10 lower
+ /// and in front of it, so the cards behind the newest peek out above
+ /// it, each 5% smaller a step back — a hand of cards. Under the pointer
+ /// the deck fans out, 8 between the cards, so every line can be read
+ /// and every cross reached; it stays fanned out under assistive
+ /// navigation, where nothing may hide behind anything.
+ static const double _peek = 10;
+ static const double _scaleStep = 0.05;
+ static const double _gap = 8;
+
+ /// A card moving up the deck, the fan opening or closing: the
+ /// jump-to-latest's 240ms.
+ static const Duration _shuffle = Duration(milliseconds: 240);
+
+ /// Each card's height, reported after its first layout; the deck is
+ /// laid out from them a frame later, the way the web's toasts measure
+ /// themselves before they stack. A card the deck cannot place yet holds
+ /// where it is.
+ final Map<_FlowToastRecord, double> _heights = {};
+ bool _hovered = false;
+ double? _deckHeight;
+ bool _sized = false;
+
+ /// The tallest the box has been under the pointer. A card dismissed
+ /// from the fan leaves a gap the pointer is still in, and a box that
+ /// closed up around the survivors would fire an exit the pointer never
+ /// made — closing the fan and restarting every clock mid-read. The room
+ /// is given back once the pointer truly leaves.
+ double? _hoverHeight;
+
+ void _measured(_FlowToastRecord record, double height) {
+ if (!mounted || _heights[record] == height) return;
+ setState(() => _heights[record] = height);
+ }
+
+ @override
+ void dispose() {
+ widget.stack.layerUnmounted();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ // Read above the SafeArea below, which consumes this padding: inside
+ // it the value always reads zero. The design's distances measure from
+ // the display's edge, so they absorb its insets rather than stacking
+ // on them — a notch would otherwise stand the card 24 below a 47pt
+ // status bar, a gap the design never drew. Simulated phone frames
+ // report no inset and keep the full distance. The chat view's rule.
+ final safe = MediaQuery.paddingOf(context);
+ final expanded = _hovered || MediaQuery.accessibleNavigationOf(context);
+ final motion = MediaQuery.disableAnimationsOf(context)
+ ? Duration.zero
+ : _shuffle;
+
+ // No Scaffold up here — the overlay sits above every route — and text
+ // with no Material ancestor takes the framework's fallback style, the
+ // preview's note.
+ return Material(
+ type: MaterialType.transparency,
+ // The layer owns the display's top edge, so it clears the insets
+ // itself. Every wrapper below is a proxy box with no hit test of
+ // its own, so a tap beside a toast reaches the page.
+ child: SafeArea(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final compact = constraints.maxWidth < _compactBreakpoint;
+ final inset = compact ? _compactInset : _wideInset;
+ return Align(
+ alignment: compact
+ ? Alignment.topCenter
+ : AlignmentDirectional.topEnd,
+ child: Padding(
+ padding: EdgeInsets.fromLTRB(
+ math.max(0, inset - safe.left),
+ math.max(0, inset - safe.top),
+ math.max(0, inset - safe.right),
+ math.max(0, inset - safe.bottom),
+ ),
+ child: SizedBox(
+ width: compact ? double.infinity : _wideWidth,
+ child: ListenableBuilder(
+ listenable: widget.stack,
+ builder: (context, _) => _deck(expanded, motion),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ );
+ }
+
+ Widget _deck(bool expanded, Duration motion) {
+ final toasts = widget.stack.toasts;
+ _heights.removeWhere((record, _) => !toasts.contains(record));
+
+ // Each card's place, oldest to newest. A leaving card holds its place
+ // while it fades and counts for no one: the card it covered is
+ // already growing back. Closed, every place follows from the count
+ // alone; fanned out, from the heights of the cards above, and null
+ // holds a card where it is until those are measured.
+ final live = [
+ for (final record in toasts)
+ if (!record.leaving) record,
+ ];
+ final count = live.length;
+ final poses = <_FlowToastRecord, _Pose?>{};
+ final covered = <_FlowToastRecord>{};
+ var offset = 0.0;
+ var measured = true;
+ for (var depth = count - 1; depth >= 0; depth--) {
+ final record = live[depth];
+ final height = _heights[record];
+ if (expanded) {
+ poses[record] = measured
+ ? _Pose(
+ dy: offset,
+ scale: 1,
+ clipBottom: (height ?? _anyCard) + _shadowSlack,
+ )
+ : null;
+ } else if (depth == 0) {
+ poses[record] = _Pose(
+ dy: _peek * (count - 1),
+ scale: 1,
+ clipBottom: (height ?? _anyCard) + _shadowSlack,
+ );
+ } else {
+ // Scaled about its top edge, which stays put; only the strip
+ // above the card in front is drawn.
+ covered.add(record);
+ final scale = 1 - _scaleStep * depth;
+ poses[record] = _Pose(
+ dy: _peek * (count - 1 - depth),
+ scale: scale,
+ clipBottom: _peek / scale,
+ );
+ }
+ if (height == null) {
+ measured = false;
+ } else {
+ offset += height + _gap;
+ }
+ }
+ if (count > 0) {
+ final frontHeight = _heights[live.first];
+ if (expanded) {
+ if (measured) _deckHeight = offset - _gap;
+ } else if (frontHeight != null) {
+ _deckHeight = _peek * (count - 1) + frontHeight;
+ }
+ }
+
+ // The deck's box is the pointer's target — a hover over the strips
+ // opens the fan too — and it grows with the fan, so the pointer never
+ // leaves it crossing a gap; under the pointer it never shrinks. The
+ // first size is taken as is; the ones after move with the cards.
+ final grow = _sized ? motion : Duration.zero;
+ if (_deckHeight != null) _sized = true;
+ if (_hovered) {
+ _hoverHeight = math.max(_hoverHeight ?? 0, _deckHeight ?? 0);
+ }
+ final boxHeight = math.max(_deckHeight ?? 0, _hoverHeight ?? 0);
+
+ return TweenAnimationBuilder(
+ tween: Tween(end: boxHeight),
+ duration: grow,
+ curve: Curves.easeOut,
+ builder: (context, height, child) =>
+ SizedBox(height: height, child: child),
+ child: MouseRegion(
+ onEnter: (_) => setState(() => _hovered = true),
+ onExit: (_) => setState(() {
+ _hovered = false;
+ _hoverHeight = null;
+ }),
+ child: Stack(
+ clipBehavior: Clip.none,
+ children: [
+ // Deepest first: the front card paints last and takes the
+ // pointer first.
+ for (final record in toasts.reversed)
+ Positioned(
+ top: 0,
+ left: 0,
+ right: 0,
+ child: _FlowToastItem(
+ key: ObjectKey(record),
+ stack: widget.stack,
+ record: record,
+ pose: poses[record],
+ covered: covered.contains(record),
+ paused: expanded,
+ motion: motion,
+ onHeight: (height) => _measured(record, height),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// One card in the deck: its entrance and exit, its clock, the frost
+/// beneath it, and the place the deck hands it.
+class _FlowToastItem extends StatefulWidget {
+ const _FlowToastItem({
+ super.key,
+ required this.stack,
+ required this.record,
+ required this.pose,
+ required this.covered,
+ required this.paused,
+ required this.motion,
+ required this.onHeight,
+ });
+
+ final _FlowToastStack stack;
+ final _FlowToastRecord record;
+
+ /// Where the deck puts the card; null holds it where it is.
+ final _Pose? pose;
+
+ /// Behind the front card with the deck closed: only its strip shows,
+ /// so it says nothing to assistive tech.
+ final bool covered;
+
+ /// The deck is fanned out: every clock waits.
+ final bool paused;
+
+ final Duration motion;
+ final ValueChanged onHeight;
+
+ @override
+ State<_FlowToastItem> createState() => _FlowToastItemState();
+}
+
+class _FlowToastItemState extends State<_FlowToastItem>
+ with SingleTickerProviderStateMixin {
+ /// Enter: fade in and settle 8 down from the edge over the drop
+ /// treatment's 150ms; exit: the fade alone, run back.
+ static const Duration _reveal = Duration(milliseconds: 150);
+ static const double _settle = 8;
+
+ /// The frost's corner — the card's 12, kept in step by hand.
+ static const BorderRadius _radius = BorderRadius.all(Radius.circular(12));
+
+ late final AnimationController _controller = AnimationController(
+ vsync: this,
+ duration: _reveal,
+ );
+ late final CurvedAnimation _curve = CurvedAnimation(
+ parent: _controller,
+ curve: Curves.easeOut,
+ );
+
+ bool _started = false;
+ bool _leaving = false;
+ Timer? _timer;
+ Duration? _remaining;
+ final Stopwatch _elapsed = Stopwatch();
+ late _Pose _pose = widget.pose ?? _Pose.open;
+
+ @override
+ void initState() {
+ super.initState();
+ _remaining = widget.record.duration;
+ widget.record.leave = _leave;
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ // Reduced motion collapses both legs to nothing. MediaQuery is a
+ // dependency, so it is read here: on the first pass to start, after
+ // that to follow a change.
+ _controller.duration = MediaQuery.disableAnimationsOf(context)
+ ? Duration.zero
+ : _reveal;
+ if (_started) return;
+ _started = true;
+ if (widget.record.dismissed) {
+ // Dismissed before it was drawn: no entrance, straight out.
+ _leaving = true;
+ widget.record.leaving = true;
+ _exit();
+ return;
+ }
+ _controller.forward();
+ _arm();
+ }
+
+ @override
+ void didUpdateWidget(_FlowToastItem oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ final pose = widget.pose;
+ if (pose != null) _pose = pose;
+ if (widget.paused != oldWidget.paused) {
+ if (widget.paused) {
+ _pause();
+ } else {
+ _arm();
+ }
+ }
+ }
+
+ @override
+ void dispose() {
+ // The record outlives this state in the handle; a late dismiss must
+ // not land on it.
+ widget.record.leave = null;
+ _timer?.cancel();
+ _curve.dispose();
+ _controller.dispose();
+ super.dispose();
+ }
+
+ /// Armed on show, paused while the deck is fanned out under the
+ /// pointer, resumed with what was left. Never for a sticky toast, nor
+ /// under assistive navigation, where a notice that leaves on its own
+ /// may leave before it was reached — the snack bar's rule.
+ void _arm() {
+ final left = _remaining;
+ if (left == null || _leaving || _timer != null || widget.paused) return;
+ if (MediaQuery.accessibleNavigationOf(context)) return;
+ _elapsed
+ ..reset()
+ ..start();
+ _timer = Timer(left, () {
+ _timer = null;
+ _leave();
+ });
+ }
+
+ void _pause() {
+ final timer = _timer;
+ if (timer == null) return;
+ timer.cancel();
+ _timer = null;
+ _elapsed.stop();
+ final left = _remaining! - _elapsed.elapsed;
+ _remaining = left.isNegative ? Duration.zero : left;
+ }
+
+ void _leave() {
+ if (_leaving) return;
+ _timer?.cancel();
+ _timer = null;
+ setState(() => _leaving = true);
+ // The card behind moves up as this one fades, not after.
+ widget.stack.leaving(widget.record);
+ _exit();
+ }
+
+ void _exit() {
+ // whenCompleteOrCancel, not await: a controller disposed mid-exit —
+ // the overlay torn down under a leaving toast — never resolves the
+ // plain future, and the stack has already closed the record.
+ _controller.reverse().whenCompleteOrCancel(() {
+ if (!mounted) return;
+ widget.stack.remove(widget.record);
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final source = widget.record.toast;
+ final card = FlowToast(
+ message: source.message,
+ icon: source.icon,
+ dismissTooltip: source.dismissTooltip,
+ style: source.style,
+ onDismiss: _leave,
+ );
+
+ // The transforms sit outermost: RenderTransform is the one box that
+ // hit-tests outside its own bounds, and a card behind the front one is
+ // drawn well outside the box it was laid out in. Everything that
+ // checks its size — the pointer and semantics gates, the clip — comes
+ // after them, in the card's own space.
+ return TweenAnimationBuilder<_Pose>(
+ tween: _PoseTween(end: _pose),
+ duration: widget.motion,
+ curve: Curves.easeOut,
+ child: _ReportHeight(onHeight: widget.onHeight, child: card),
+ builder: (context, pose, child) => AnimatedBuilder(
+ animation: _curve,
+ child: child,
+ builder: (context, child) {
+ final t = _curve.value;
+ // Canvas transforms, not offscreen layers, so the frost below
+ // still samples the page through them.
+ return Transform.translate(
+ offset: Offset(0, pose.dy - _settle * (1 - t)),
+ child: Transform.scale(
+ scale: pose.scale,
+ alignment: Alignment.topCenter,
+ child: IgnorePointer(
+ ignoring: _leaving,
+ child: ExcludeSemantics(
+ excluding: _leaving || widget.covered,
+ // Behind the front card only the strip is drawn: what
+ // the card in front covers never paints, so nothing
+ // ghosts through its frost.
+ child: ClipRect(
+ clipper: _DeckClipper(pose.clipBottom),
+ child: Stack(
+ fit: StackFit.passthrough,
+ children: [
+ // The frost is a sibling *behind* the card, never
+ // an ancestor of its fade: anything inside an
+ // Opacity joins that layer, and a BackdropFilter
+ // in there samples the layer, not the page — the
+ // preview's rule. Opacity skips its layer at 1, so
+ // a settled toast costs one filter and nothing
+ // else.
+ Positioned.fill(
+ child: ClipRRect(
+ borderRadius: _radius,
+ child: BackdropFilter(
+ filter: _blurFilter,
+ child: const SizedBox.expand(),
+ ),
+ ),
+ ),
+ Opacity(opacity: t, child: child),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
+/// The card's clip: down to [bottom] — the strip's foot, or past the
+/// card's own edge — with the slack kept above and at the sides for the
+/// shadow. Hit tests follow the clip, so a covered card takes the pointer
+/// on its strip alone.
+class _DeckClipper extends CustomClipper {
+ const _DeckClipper(this.bottom);
+
+ final double bottom;
+
+ @override
+ Rect getClip(Size size) => Rect.fromLTRB(
+ -_shadowSlack,
+ -_shadowSlack,
+ size.width + _shadowSlack,
+ math.min(bottom, size.height + _shadowSlack),
+ );
+
+ @override
+ bool shouldReclip(_DeckClipper oldClipper) => oldClipper.bottom != bottom;
+}
+
+/// Reports the card's laid-out height to the deck once the frame is done;
+/// the deck places the cards from it on the next.
+class _ReportHeight extends SingleChildRenderObjectWidget {
+ const _ReportHeight({required this.onHeight, required super.child});
+
+ final ValueChanged onHeight;
+
+ @override
+ RenderObject createRenderObject(BuildContext context) =>
+ _RenderReportHeight(onHeight);
+
+ @override
+ void updateRenderObject(
+ BuildContext context,
+ _RenderReportHeight renderObject,
+ ) {
+ renderObject.onHeight = onHeight;
+ }
+}
+
+class _RenderReportHeight extends RenderProxyBox {
+ _RenderReportHeight(this.onHeight);
+
+ ValueChanged onHeight;
+ double? _reported;
+
+ @override
+ void performLayout() {
+ super.performLayout();
+ final height = size.height;
+ if (height == _reported) return;
+ _reported = height;
+ WidgetsBinding.instance.addPostFrameCallback((_) => onHeight(height));
+ }
+}
diff --git a/lib/src/widgets/flow_toast.dart b/lib/src/widgets/flow_toast.dart
new file mode 100644
index 0000000..6afe811
--- /dev/null
+++ b/lib/src/widgets/flow_toast.dart
@@ -0,0 +1,217 @@
+import 'package:material_ui/material_ui.dart';
+
+import '../styles/flow_toast_style.dart';
+import '../theme/flow_theme.dart';
+import '../utils/flow_circle_button.dart';
+
+/// The floating notice: a glyph, one line that wraps, and a cross on the
+/// raised card — "Message copied to clipboard", "Image upload failed".
+///
+/// ```dart
+/// FlowToast(
+/// icon: Icons.copy_outlined,
+/// message: 'Message copied to clipboard',
+/// dismissTooltip: 'Dismiss',
+/// onDismiss: hide,
+/// )
+/// ```
+///
+/// This is the card alone. It renders state and reports one intent,
+/// dismiss; where it floats, how long it stays and what happens when
+/// three arrive at once is a lifecycle, and `showFlowToast` owns it — one
+/// call floats the card in the nearest `Overlay` and hands back a handle.
+/// Build the card directly to own that lifecycle yourself: in a stack of
+/// the host's own, an `AnimatedSwitcher`, an `Overlay` it scopes.
+///
+/// The glyph is the host's, and carries the meaning — a copy icon for the
+/// clipboard, a warning circle for a failed upload — recolored through
+/// [FlowToastStyle.iconColor]. The line stays in the ink ramp whatever the
+/// glyph says: the light accents fall short of WCAG AA for text on the
+/// card, the confirmation card's rule. The package ships no strings:
+/// [message] and [dismissTooltip] are host-localized.
+class FlowToast extends StatelessWidget {
+ const FlowToast({
+ super.key,
+ required this.message,
+ this.icon,
+ this.onDismiss,
+ this.dismissTooltip,
+ this.padding,
+ this.borderRadius,
+ this.style,
+ });
+
+ /// The notice, host-written and sentence-case; wraps when long.
+ /// Announced to assistive tech as a live region, since notices arrive
+ /// unprompted.
+ final String message;
+
+ /// The leading glyph. Null draws none, and the line starts at the
+ /// card's edge.
+ final IconData? icon;
+
+ /// Dismiss intent. Null draws no cross.
+ final VoidCallback? onDismiss;
+
+ /// Host-localized label for the cross, e.g. 'Dismiss'; also its
+ /// accessible name.
+ final String? dismissTooltip;
+
+ /// Inside the card. Defaults to the design's 16 at the start, 12
+ /// elsewhere.
+ final EdgeInsetsGeometry? padding;
+
+ /// The card's corner. Defaults to the design's 12.
+ final BorderRadius? borderRadius;
+
+ /// Per-instance restyling, merged over [FlowTheme.toastStyle]'s fields;
+ /// nulls fall through to the theme tokens.
+ final FlowToastStyle? style;
+
+ /// The card: the raised surface at 80%, so the page reads through it,
+ /// under the firm hairline on a 12px corner, with the composer's error
+ /// tab's lift — the theme's shadow at a 24 blur. Padded 16 at the start
+ /// and 12 elsewhere, 10 between glyph, line and cross. The frost the
+ /// design draws beneath belongs to whoever floats the card (see
+ /// `showFlowToast`): a blur inside the card would sit inside its fade,
+ /// and a BackdropFilter under an Opacity samples the fade's own layer,
+ /// not the page.
+ static const BorderRadius _radius = BorderRadius.all(Radius.circular(12));
+ static const EdgeInsetsGeometry _cardPadding = EdgeInsetsDirectional.fromSTEB(
+ 16,
+ 12,
+ 12,
+ 12,
+ );
+ static const double _fillOpacity = 0.8;
+ static const double _shadowBlur = 24;
+
+ /// The glyph, the design's 18, centred on the line's first row; the
+ /// cross is the composer banner's — a 16 glyph on a 4 pad, a 24 disc
+ /// centred on the same row.
+ static const double _iconSize = 18;
+ static const double _gap = 10;
+ static const double _dismissIconSize = 16;
+ static const double _dismissPadding = 4;
+ static const double _dismissDisc = _dismissIconSize + _dismissPadding * 2;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = context.flowColors;
+ final typography = context.flowTypography;
+ final icon = this.icon;
+ final onDismiss = this.onDismiss;
+
+ final effective = context.flowTheme.toastStyle?.merge(style) ?? style;
+
+ final lineStyle = typography.labelMediumEmphasised
+ .copyWith(color: colors.onSurface)
+ .merge(effective?.messageStyle);
+
+ // The glyph and the cross centre on the line's *first* row — a box
+ // the row's own height keeps them optically centred beside a one-line
+ // notice and on the opening line of a wrapping one alike.
+ final firstLineHeight =
+ (lineStyle.fontSize ?? _iconSize) * (lineStyle.height ?? 1);
+
+ final line = Row(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (icon != null) ...[
+ SizedBox(
+ height: firstLineHeight,
+ child: Center(
+ child: ExcludeSemantics(
+ child: Icon(
+ icon,
+ size: _iconSize,
+ color: effective?.iconColor ?? colors.onSurface,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: _gap),
+ ],
+ // Notices arrive unprompted: announce the line.
+ Flexible(
+ child: Semantics(
+ liveRegion: true,
+ child: Text(message, style: lineStyle),
+ ),
+ ),
+ ],
+ );
+
+ // The cross pins to the end edge from outside the row, so the padding
+ // is split by hand — start on the line, end after the disc — kept
+ // directional so RTL swaps them, the pill's idiom.
+ final direction = Directionality.of(context);
+ final resolved = (padding ?? _cardPadding).resolve(direction);
+ final startInset = direction == TextDirection.ltr
+ ? resolved.left
+ : resolved.right;
+ final endInset = direction == TextDirection.ltr
+ ? resolved.right
+ : resolved.left;
+
+ final Widget content;
+ if (onDismiss == null) {
+ content = Padding(padding: resolved, child: line);
+ } else {
+ // The disc is taller than the row it centres on, so it sits outside
+ // the row: the line reserves its width, and the disc is pinned from
+ // the end edge, lifted half the difference above the row's top. The
+ // card keeps the design's height for one line and for three, and
+ // the whole disc stays inside it, so all of it takes a tap.
+ content = Stack(
+ children: [
+ Padding(
+ padding: EdgeInsetsDirectional.only(
+ start: startInset,
+ end: endInset + _dismissDisc + _gap,
+ top: resolved.top,
+ bottom: resolved.bottom,
+ ),
+ child: line,
+ ),
+ PositionedDirectional(
+ end: endInset,
+ top: resolved.top - (_dismissDisc - firstLineHeight) / 2,
+ child: FlowCircleButton(
+ icon: Icons.close,
+ background: const Color(0x00000000),
+ foreground:
+ effective?.dismissIconColor ?? colors.onSurfaceVariant,
+ hoverColor: colors.surfaceContainer,
+ iconSize: _dismissIconSize,
+ padding: _dismissPadding,
+ tooltip: dismissTooltip,
+ onTap: onDismiss,
+ ),
+ ),
+ ],
+ );
+ }
+
+ // A Container rather than a DecoratedBox: it insets the content by the
+ // hairline, so the padding measures from inside the stroke as on the
+ // other cards.
+ return Semantics(
+ container: true,
+ child: Container(
+ decoration: BoxDecoration(
+ color:
+ effective?.backgroundColor ??
+ colors.surfaceBright.withValues(alpha: _fillOpacity),
+ borderRadius: borderRadius ?? _radius,
+ border: Border.all(
+ color: effective?.borderColor ?? colors.outlineVariant,
+ ),
+ boxShadow: [BoxShadow(color: colors.shadow, blurRadius: _shadowBlur)],
+ ),
+ child: content,
+ ),
+ );
+ }
+}
diff --git a/playground/lib/src/demo_registry.dart b/playground/lib/src/demo_registry.dart
index 5e9b75b..685e1ab 100644
--- a/playground/lib/src/demo_registry.dart
+++ b/playground/lib/src/demo_registry.dart
@@ -20,6 +20,7 @@ import 'demos/suggestions_demo.dart';
import 'demos/thinking_indicator_demo.dart';
import 'demos/thread_demo.dart';
import 'demos/thread_list_demo.dart';
+import 'demos/toast_demo.dart';
import 'playground_item.dart';
/// The stage's demo for [item]. Keyed on the variant so switching pills
@@ -40,6 +41,7 @@ Widget demoFor(PlaygroundItem item, {String? variant}) {
PlaygroundItem.markdown => MarkdownDemo(key: key, variant: variant),
PlaygroundItem.errorState => ErrorStateDemo(key: key, variant: variant),
PlaygroundItem.confirmation => ConfirmationDemo(key: key, variant: variant),
+ PlaygroundItem.toast => ToastDemo(key: key, variant: variant),
PlaygroundItem.addToChat => AddToChatDemo(key: key),
PlaygroundItem.pill => PillDemo(key: key, variant: variant),
PlaygroundItem.attachments => AttachmentsDemo(key: key, variant: variant),
@@ -106,6 +108,13 @@ List<(String, String)> variantsFor(PlaygroundItem item) {
('rejected', 'Rejected'),
('thread', 'In a thread'),
],
+ PlaygroundItem.toast => const [
+ ('default', 'Neutral'),
+ ('error', 'Error'),
+ ('sticky', 'Sticky'),
+ ('stacked', 'Stacked'),
+ ('card', 'Card'),
+ ],
PlaygroundItem.pill => const [
('default', 'Default'),
('icon', 'Icon only'),
@@ -155,10 +164,12 @@ List<(String, String)> variantsFor(PlaygroundItem item) {
/// Whether the demo takes the whole stage pane (a full surface) rather
/// than sitting as an object on the canvas. The chat always does; the
/// attachments stage does for its drop variant, which is a chat surface
-/// with the treatment pinned up.
+/// with the treatment pinned up; the toast does for every variant but the
+/// bare card, since a toast floats over a surface's edge.
bool demoFillsStage(PlaygroundItem item, {String? variant}) =>
item == PlaygroundItem.fullChat ||
- (item == PlaygroundItem.attachments && variant == 'drop');
+ (item == PlaygroundItem.attachments && variant == 'drop') ||
+ (item == PlaygroundItem.toast && variant != 'card');
/// The code panel's snippet for [item] — the real flow_ui usage, not the
/// demo's plumbing. [variant] is the stage's active pill, so the code
@@ -174,6 +185,7 @@ String snippetFor(PlaygroundItem item, {String? variant}) {
PlaygroundItem.markdown => markdownSnippet(variant),
PlaygroundItem.errorState => errorStateSnippet(variant),
PlaygroundItem.confirmation => confirmationSnippet(variant),
+ PlaygroundItem.toast => toastSnippet(variant),
PlaygroundItem.addToChat => addToChatSnippet,
PlaygroundItem.pill => pillSnippet(variant),
PlaygroundItem.attachments => attachmentsSnippet(variant),
diff --git a/playground/lib/src/demos/toast_demo.dart b/playground/lib/src/demos/toast_demo.dart
new file mode 100644
index 0000000..476ac53
--- /dev/null
+++ b/playground/lib/src/demos/toast_demo.dart
@@ -0,0 +1,364 @@
+import 'dart:async';
+
+import 'package:flow_ui/flow_ui.dart';
+import 'package:material_ui/material_ui.dart';
+import 'package:phosphoricons_flutter/phosphoricons_flutter.dart';
+
+String toastSnippet([String? variant]) => switch (variant) {
+ 'error' => _errorSnip,
+ 'sticky' => _stickySnip,
+ 'stacked' => _stackedSnip,
+ 'card' => _cardSnip,
+ _ => _defaultSnip,
+};
+
+const String _defaultSnip = '''
+// One call floats the card in the nearest Overlay — no host widget to
+// wire — and it leaves on its own after four seconds, or from its cross.
+showFlowToast(
+ context: context,
+ icon: PhosphorIconsRegular.copySimple,
+ message: 'Message copied to clipboard',
+ dismissTooltip: 'Dismiss',
+)''';
+
+const String _errorSnip = '''
+// The glyph carries the meaning: a failure is the warning circle in the
+// error accent. The line stays in the ink ramp.
+showFlowToast(
+ context: context,
+ icon: PhosphorIconsRegular.warningCircle,
+ message: 'Image upload failed. Try again',
+ dismissTooltip: 'Dismiss',
+ style: FlowToastStyle(iconColor: context.flowColors.error),
+)''';
+
+const String _stickySnip = '''
+// A null duration keeps the toast up until it is dismissed — the handle
+// is how the host takes it down once the work is done.
+final handle = showFlowToast(
+ context: context,
+ icon: PhosphorIconsRegular.uploadSimple,
+ message: 'Uploading 3 files…',
+ duration: null,
+ dismissTooltip: 'Dismiss',
+);
+await upload(files);
+handle.dismiss();
+showFlowToast(
+ context: context,
+ icon: PhosphorIconsRegular.checkCircle,
+ message: '3 files uploaded',
+ dismissTooltip: 'Dismiss',
+ style: FlowToastStyle(iconColor: context.flowColors.success),
+)''';
+
+const String _stackedSnip = '''
+// Toasts stack as a deck: the newest in front, the rest peeking out
+// behind it, three at most. Hovering fans the deck out and pauses
+// every clock.
+for (final notice in notices) {
+ showFlowToast(
+ context: context,
+ icon: notice.icon,
+ message: notice.message,
+ dismissTooltip: 'Dismiss',
+ );
+}''';
+
+const String _cardSnip = '''
+// The card alone: state in, one intent out — for a host that owns the
+// lifecycle (its own stack, its own clock) instead of showFlowToast.
+FlowToast(
+ icon: PhosphorIconsRegular.copySimple,
+ message: 'Message copied to clipboard',
+ dismissTooltip: 'Dismiss',
+ onDismiss: hide,
+)
+
+FlowToast(
+ icon: PhosphorIconsRegular.warningCircle,
+ message: 'Image upload failed. Try again',
+ dismissTooltip: 'Dismiss',
+ onDismiss: hide,
+ style: FlowToastStyle(iconColor: context.flowColors.error),
+)''';
+
+/// Stage demo for `FlowToast` — a surface with an Overlay of its own that
+/// `showFlowToast` floats into: the neutral and failure notices, a sticky
+/// one that settles when its work finishes, the stack and its eviction,
+/// and the card on its own.
+class ToastDemo extends StatefulWidget {
+ const ToastDemo({super.key, this.variant});
+
+ final String? variant;
+
+ @override
+ State createState() => _ToastDemoState();
+}
+
+class _ToastDemoState extends State {
+ /// The card variant's width: the design's, so the cards read as drawn.
+ static const double _cardWidth = 358;
+ static const double _cardGap = 12;
+
+ /// The sticky variant's pretend upload, and how long a dismissed card
+ /// stays away before the card variant puts it back.
+ static const Duration _uploadTime = Duration(seconds: 3);
+ static const Duration _cardReturn = Duration(milliseconds: 1500);
+
+ /// The stacked variant's notices, three per tap, round and round.
+ static const List<(IconData, String)> _notices = [
+ (PhosphorIconsRegular.copySimple, 'Message copied to clipboard'),
+ (PhosphorIconsRegular.link, 'Link copied'),
+ (PhosphorIconsRegular.floppyDisk, 'Draft saved'),
+ (PhosphorIconsRegular.warningCircle, 'Image upload failed. Try again'),
+ (PhosphorIconsRegular.checkCircle, '3 files uploaded'),
+ (PhosphorIconsRegular.bellSimple, 'Notifications are on'),
+ ];
+ int _next = 0;
+
+ Timer? _upload;
+
+ /// Card variant: which cards are dismissed, and the clocks that bring
+ /// them back so the stage is never left empty.
+ final Set _hidden = {};
+ final Map _returns = {};
+
+ @override
+ void dispose() {
+ _upload?.cancel();
+ for (final timer in _returns.values) {
+ timer.cancel();
+ }
+ super.dispose();
+ }
+
+ /// [context] must sit inside the demo's Overlay — the Builder's below,
+ /// not this state's, which would resolve to the playground's own.
+ void _show(BuildContext context) {
+ final colors = context.flowColors;
+ switch (widget.variant) {
+ case 'error':
+ showFlowToast(
+ context: context,
+ icon: PhosphorIconsRegular.warningCircle,
+ message: 'Image upload failed. Try again',
+ dismissTooltip: 'Dismiss',
+ style: FlowToastStyle(iconColor: colors.error),
+ );
+ case 'sticky':
+ // Up until the work is done, the way a host would hold it; the
+ // outcome takes its place.
+ final handle = showFlowToast(
+ context: context,
+ icon: PhosphorIconsRegular.uploadSimple,
+ message: 'Uploading 3 files…',
+ duration: null,
+ dismissTooltip: 'Dismiss',
+ );
+ _upload?.cancel();
+ _upload = Timer(_uploadTime, () {
+ _upload = null;
+ if (!mounted || !context.mounted) return;
+ handle.dismiss();
+ showFlowToast(
+ context: context,
+ icon: PhosphorIconsRegular.checkCircle,
+ message: '3 files uploaded',
+ dismissTooltip: 'Dismiss',
+ style: FlowToastStyle(iconColor: colors.success),
+ );
+ });
+ case 'stacked':
+ // Three at a time: the first tap fills the stack, the next one
+ // pushes it through.
+ for (var i = 0; i < 3; i++) {
+ final (icon, message) = _notices[_next % _notices.length];
+ _next++;
+ showFlowToast(
+ context: context,
+ icon: icon,
+ message: message,
+ dismissTooltip: 'Dismiss',
+ style: switch (icon) {
+ PhosphorIconsRegular.warningCircle => FlowToastStyle(
+ iconColor: colors.error,
+ ),
+ PhosphorIconsRegular.checkCircle => FlowToastStyle(
+ iconColor: colors.success,
+ ),
+ _ => null,
+ },
+ );
+ }
+ default:
+ showFlowToast(
+ context: context,
+ icon: PhosphorIconsRegular.copySimple,
+ message: 'Message copied to clipboard',
+ dismissTooltip: 'Dismiss',
+ );
+ }
+ }
+
+ void _hideCard(int index) {
+ setState(() => _hidden.add(index));
+ _returns[index]?.cancel();
+ _returns[index] = Timer(_cardReturn, () {
+ _returns.remove(index);
+ if (mounted) setState(() => _hidden.remove(index));
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (widget.variant == 'card') return _cards(context);
+
+ final (icon, label) = switch (widget.variant) {
+ 'error' => (PhosphorIconsRegular.warningCircle, 'Fail an upload'),
+ 'sticky' => (PhosphorIconsRegular.uploadSimple, 'Upload 3 files'),
+ 'stacked' => (PhosphorIconsRegular.stack, 'Show three'),
+ _ => (PhosphorIconsRegular.copySimple, 'Copy a message'),
+ };
+
+ // The live variants take the whole stage, so the toast floats where it
+ // would on a real surface — the pane's top end corner on the web
+ // canvas, the top of the screen in the phone, the iframe in the docs —
+ // inside an Overlay of the demo's own: showFlowToast floats in the
+ // nearest one, so the toasts land here rather than over the
+ // playground's chrome.
+ return Overlay.wrap(
+ child: Center(
+ child: Builder(
+ builder: (context) => _TriggerPill(
+ icon: icon,
+ label: label,
+ onTap: () => _show(context),
+ ),
+ ),
+ ),
+ );
+ }
+
+ /// The card on its own, in both drawn forms. Its cross reports intent
+ /// and the demo hides the card, then brings it back a moment later.
+ Widget _cards(BuildContext context) {
+ final colors = context.flowColors;
+ final cards = [
+ FlowToast(
+ icon: PhosphorIconsRegular.copySimple,
+ message: 'Message copied to clipboard',
+ dismissTooltip: 'Dismiss',
+ onDismiss: () => _hideCard(0),
+ ),
+ FlowToast(
+ icon: PhosphorIconsRegular.warningCircle,
+ message: 'Image upload failed. Try again',
+ dismissTooltip: 'Dismiss',
+ onDismiss: () => _hideCard(1),
+ style: FlowToastStyle(iconColor: colors.error),
+ ),
+ ];
+
+ return Center(
+ child: SizedBox(
+ width: _cardWidth,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ for (final (i, card) in cards.indexed) ...[
+ if (i > 0) const SizedBox(height: _cardGap),
+ if (_hidden.contains(i))
+ // Hold the card's footprint so the other one stays put.
+ const SizedBox(height: 46)
+ else
+ card,
+ ],
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// The stage's trigger: the retry pill's frame — 32 tall on an 8px
+/// corner, the firm hairline, the ink lifting on hover — private to the
+/// demo until the design system's Button lands.
+class _TriggerPill extends StatefulWidget {
+ const _TriggerPill({
+ required this.icon,
+ required this.label,
+ required this.onTap,
+ });
+
+ final IconData icon;
+ final String label;
+ final VoidCallback onTap;
+
+ @override
+ State<_TriggerPill> createState() => _TriggerPillState();
+}
+
+class _TriggerPillState extends State<_TriggerPill> {
+ static const double _height = 32;
+ static const BorderRadius _radius = BorderRadius.all(Radius.circular(8));
+ static const EdgeInsetsGeometry _padding = EdgeInsets.symmetric(
+ horizontal: 12,
+ );
+ static const double _glyphSize = 14;
+ static const double _glyphGap = 6;
+
+ bool _hovered = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = context.flowColors;
+ final typography = context.flowTypography;
+
+ final foreground = _hovered ? colors.onSurface : colors.onSurfaceVariant;
+ final shape = RoundedRectangleBorder(
+ borderRadius: _radius,
+ side: BorderSide(color: colors.outlineVariant),
+ );
+
+ return Semantics(
+ button: true,
+ label: widget.label,
+ excludeSemantics: true,
+ onTap: widget.onTap,
+ child: Material(
+ color: Colors.transparent,
+ shape: shape,
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: widget.onTap,
+ onHover: (value) => setState(() => _hovered = value),
+ customBorder: shape,
+ hoverColor: colors.surfaceContainerLow,
+ child: SizedBox(
+ height: _height,
+ child: Padding(
+ padding: _padding,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(widget.icon, size: _glyphSize, color: foreground),
+ const SizedBox(width: _glyphGap),
+ Text(
+ widget.label,
+ style: FlowTypography.recut(
+ typography.labelMedium,
+ fontWeight: FontWeight.w600,
+ ).copyWith(color: foreground),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/playground/lib/src/playground_item.dart b/playground/lib/src/playground_item.dart
index a1b678f..0981389 100644
--- a/playground/lib/src/playground_item.dart
+++ b/playground/lib/src/playground_item.dart
@@ -40,6 +40,7 @@ enum PlaygroundItem {
PhosphorIconsRegular.shieldCheck,
'flow_confirmation.dart',
),
+ toast('Toast', PhosphorIconsRegular.bellSimple, 'flow_toast.dart'),
addToChat(
'Add to Chat',
PhosphorIconsRegular.plus,
diff --git a/playground/lib/src/stage.dart b/playground/lib/src/stage.dart
index deeab67..3291db2 100644
--- a/playground/lib/src/stage.dart
+++ b/playground/lib/src/stage.dart
@@ -29,6 +29,13 @@ class Stage extends StatelessWidget {
final String? variant;
final ValueChanged? onVariantChanged;
+ /// The object rail's top padding, which clears the pill switcher. A
+ /// full-surface demo with variants learns of the pills the same way,
+ /// as a top inset its SafeArea clears — the way a screen learns of its
+ /// status bar — so anything it anchors to the top edge (a toast) lands
+ /// below them.
+ static const double _chromeInset = 56;
+
@override
Widget build(BuildContext context) {
final demo = demoFor(item, variant: variant);
@@ -38,17 +45,32 @@ class Stage extends StatelessWidget {
if (device == StageDevice.web) {
// A full-surface demo (the chat) owns the whole pane; object demos
// sit centred on the canvas.
- content = demoFillsStage(item, variant: variant)
- ? demo
- : Center(
- child: SingleChildScrollView(
- padding: const EdgeInsets.fromLTRB(28, 56, 28, 52),
- child: ConstrainedBox(
- constraints: const BoxConstraints(maxWidth: 860),
- child: demo,
+ if (demoFillsStage(item, variant: variant)) {
+ // Only the top changes: the display's own insets stay, so a
+ // surface below still absorbs a home indicator the way it does
+ // outside the stage.
+ final padding = MediaQuery.paddingOf(context);
+ content = variants.isEmpty
+ ? demo
+ : MediaQuery(
+ data: MediaQuery.of(context).copyWith(
+ padding: padding.copyWith(
+ top: math.max(padding.top, _chromeInset),
+ ),
),
- ),
- );
+ child: demo,
+ );
+ } else {
+ content = Center(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(28, _chromeInset, 28, 52),
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 860),
+ child: demo,
+ ),
+ ),
+ );
+ }
} else {
content = LayoutBuilder(
builder: (context, constraints) => Center(