From 2e74dd856dd14581191a302f7bc1de33b6e2ecd1 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Wed, 2 Sep 2026 03:55:31 +0530 Subject: [PATCH 1/5] feat: add FlowToast, the floating notice, and showFlowToast --- AGENTS.md | 1 + CHANGELOG.md | 12 + CLAUDE.md | 1 + README.md | 1 + docs/public/_redirects | 1 + docs/src/content/docs/components/toast.mdx | 157 +++++++ docs/src/content/docs/roadmap.md | 1 + docs/src/content/docs/theming.mdx | 2 +- example/lib/main.dart | 9 +- lib/flow_ui.dart | 3 + lib/src/styles/flow_toast_style.dart | 90 ++++ lib/src/theme/flow_theme.dart | 11 + lib/src/utils/flow_toast_layer.dart | 465 +++++++++++++++++++++ lib/src/widgets/flow_toast.dart | 217 ++++++++++ playground/lib/src/demo_registry.dart | 16 +- playground/lib/src/demos/toast_demo.dart | 364 ++++++++++++++++ playground/lib/src/playground_item.dart | 1 + playground/lib/src/stage.dart | 38 +- 18 files changed, 1373 insertions(+), 17 deletions(-) create mode 100644 docs/src/content/docs/components/toast.mdx create mode 100644 lib/src/styles/flow_toast_style.dart create mode 100644 lib/src/utils/flow_toast_layer.dart create mode 100644 lib/src/widgets/flow_toast.dart create mode 100644 playground/lib/src/demos/toast_demo.dart 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 536c855..8ce852c 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, newest nearest the edge, + 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. - **Confirmation** — `FlowConfirmation`, the approval card: an asterisk-marked request with approve and reject buttons that settle into the outcome, every label host-localized. `FlowConfirmationPart` 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 6b996dc..05c0d9d 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..1dad42f --- /dev/null +++ b/docs/src/content/docs/components/toast.mdx @@ -0,0 +1,157 @@ +--- +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 a toast pauses its 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, newest nearest the edge and 8 apart, three at most: a +fourth dismisses the oldest. Each keeps its own clock. + + + +## 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 keep clear of the display's safe area. + +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..432a8e9 --- /dev/null +++ b/lib/src/utils/flow_toast_layer.dart @@ -0,0 +1,465 @@ +import 'dart:async'; +import 'dart:ui' show ImageFilter; + +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 inside the display's safe area. +/// Newer toasts stack above older ones, 8 apart, three at most; a fourth +/// dismisses the oldest. +/// +/// A toast leaves on its own after [duration] — four seconds unless told +/// otherwise, the clock paused while the pointer is over it — 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; + + 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, 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 in a column 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(); +} + +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. Toasts stack 8 apart, newest nearest + /// the edge. + static const double _compactBreakpoint = 600; + static const double _compactInset = 16; + static const double _wideInset = 24; + static const double _wideWidth = 358; + static const double _gap = 8; + + @override + void dispose() { + widget.stack.layerUnmounted(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // 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; + return Align( + alignment: compact + ? Alignment.topCenter + : AlignmentDirectional.topEnd, + child: Padding( + padding: EdgeInsets.all(compact ? _compactInset : _wideInset), + child: SizedBox( + width: compact ? double.infinity : _wideWidth, + child: ListenableBuilder( + listenable: widget.stack, + builder: (context, _) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final (i, record) + in widget.stack.toasts.indexed) ...[ + if (i > 0) const SizedBox(height: _gap), + _FlowToastItem( + key: ObjectKey(record), + stack: widget.stack, + record: record, + ), + ], + ], + ), + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +/// One toast in the layer: its entrance and exit, its clock, and the frost +/// beneath it. +class _FlowToastItem extends StatefulWidget { + const _FlowToastItem({super.key, required this.stack, required this.record}); + + final _FlowToastStack stack; + final _FlowToastRecord record; + + @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(); + + @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; + _exit(); + return; + } + _controller.forward(); + _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 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) 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); + _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, + ); + + return IgnorePointer( + ignoring: _leaving, + child: ExcludeSemantics( + excluding: _leaving, + child: MouseRegion( + onEnter: (_) => _pause(), + onExit: (_) => _arm(), + child: AnimatedBuilder( + animation: _curve, + child: card, + builder: (context, child) { + final t = _curve.value; + // A canvas transform, not an offscreen layer, so the frost + // below still samples the page through it. + return Transform.translate( + offset: Offset(0, -_settle * (1 - t)), + 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), + ], + ), + ); + }, + ), + ), + ), + ); + } +} 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..027833d --- /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, newest nearest the edge, three at most: a fourth +// dismisses the oldest. Each keeps its own clock, paused under the +// pointer. +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..6f5598a 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,26 @@ 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)) { + content = variants.isEmpty + ? demo + : MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(padding: const EdgeInsets.only(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( From 848520332bf7c6ee1fb2b8b03808177c3cf681fa Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Thu, 3 Sep 2026 18:13:23 +0530 Subject: [PATCH 2/5] fix: absorb the display insets into the toast's placement --- docs/src/content/docs/components/toast.mdx | 3 ++- lib/src/utils/flow_toast_layer.dart | 21 +++++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/components/toast.mdx b/docs/src/content/docs/components/toast.mdx index 1dad42f..c01c720 100644 --- a/docs/src/content/docs/components/toast.mdx +++ b/docs/src/content/docs/components/toast.mdx @@ -89,7 +89,8 @@ fourth dismisses the oldest. Each keeps its own clock. 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 keep clear of the display's safe area. +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 diff --git a/lib/src/utils/flow_toast_layer.dart b/lib/src/utils/flow_toast_layer.dart index 432a8e9..f18cada 100644 --- a/lib/src/utils/flow_toast_layer.dart +++ b/lib/src/utils/flow_toast_layer.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'dart:ui' show ImageFilter; import 'package:material_ui/material_ui.dart'; @@ -35,7 +36,9 @@ final ImageFilter _blurFilter = ImageFilter.blur(sigmaX: 3, sigmaY: 3); /// /// 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 inside the display's safe area. +/// 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. /// Newer toasts stack above older ones, 8 apart, three at most; a fourth /// dismisses the oldest. /// @@ -238,6 +241,14 @@ class _FlowToastLayerState extends State<_FlowToastLayer> { @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); + // 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. @@ -250,12 +261,18 @@ class _FlowToastLayerState extends State<_FlowToastLayer> { 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.all(compact ? _compactInset : _wideInset), + 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( From dd093f979362c8c01905cf1e9dc699200fc0e887 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Thu, 3 Sep 2026 18:13:28 +0530 Subject: [PATCH 3/5] feat: stack toasts as a deck, the newest in front --- CHANGELOG.md | 2 +- docs/src/content/docs/components/toast.mdx | 13 +- lib/src/utils/flow_toast_layer.dart | 446 +++++++++++++++++---- playground/lib/src/demos/toast_demo.dart | 6 +- 4 files changed, 390 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52604dc..e226291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ 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, newest nearest the edge, + 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. diff --git a/docs/src/content/docs/components/toast.mdx b/docs/src/content/docs/components/toast.mdx index c01c720..86e2266 100644 --- a/docs/src/content/docs/components/toast.mdx +++ b/docs/src/content/docs/components/toast.mdx @@ -60,8 +60,8 @@ showFlowToast( 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 a toast pauses its clock, so a line that is being read -does not vanish mid-sentence. +still up. Hovering the deck pauses every clock, so a line that is being +read does not vanish mid-sentence. @@ -79,8 +79,13 @@ handle.dismiss(); ## Several at once -Toasts stack, newest nearest the edge and 8 apart, three at most: a -fourth dismisses the oldest. Each keeps its own clock. +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. diff --git a/lib/src/utils/flow_toast_layer.dart b/lib/src/utils/flow_toast_layer.dart index f18cada..1591f54 100644 --- a/lib/src/utils/flow_toast_layer.dart +++ b/lib/src/utils/flow_toast_layer.dart @@ -1,7 +1,8 @@ import 'dart:async'; import 'dart:math' as math; -import 'dart:ui' show ImageFilter; +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'; @@ -39,11 +40,16 @@ final ImageFilter _blurFilter = ImageFilter.blur(sigmaX: 3, sigmaY: 3); /// 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. -/// Newer toasts stack above older ones, 8 apart, three at most; a fourth -/// dismisses the oldest. +/// +/// 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, the clock paused while the pointer is over it — or at once +/// 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 @@ -114,6 +120,10 @@ class _FlowToastRecord { 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; @@ -162,6 +172,13 @@ class _FlowToastStack extends ChangeNotifier { 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; @@ -210,8 +227,8 @@ class _FlowToastStack extends ChangeNotifier { } } -/// The entry's widget: the stack's toasts in a column at the overlay's -/// top edge, placed by the overlay's own width. +/// 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}); @@ -221,18 +238,100 @@ class _FlowToastLayer extends StatefulWidget { 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. Toasts stack 8 apart, newest nearest - /// the edge. + /// 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; + + void _measured(_FlowToastRecord record, double height) { + if (!mounted || _heights[record] == height) return; + setState(() => _heights[record] = height); + } + @override void dispose() { widget.stack.layerUnmounted(); @@ -248,6 +347,10 @@ class _FlowToastLayerState extends State<_FlowToastLayer> { // 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 @@ -277,21 +380,7 @@ class _FlowToastLayerState extends State<_FlowToastLayer> { width: compact ? double.infinity : _wideWidth, child: ListenableBuilder( listenable: widget.stack, - builder: (context, _) => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final (i, record) - in widget.stack.toasts.indexed) ...[ - if (i > 0) const SizedBox(height: _gap), - _FlowToastItem( - key: ObjectKey(record), - stack: widget.stack, - record: record, - ), - ], - ], - ), + builder: (context, _) => _deck(expanded, motion), ), ), ), @@ -301,16 +390,142 @@ class _FlowToastLayerState extends State<_FlowToastLayer> { ), ); } + + 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. 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; + + return TweenAnimationBuilder( + tween: Tween(end: _deckHeight ?? 0), + duration: grow, + curve: Curves.easeOut, + builder: (context, height, child) => + SizedBox(height: height, child: child), + child: MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + 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 toast in the layer: its entrance and exit, its clock, and the frost -/// beneath it. +/// 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}); + 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(); } @@ -339,6 +554,7 @@ class _FlowToastItemState extends State<_FlowToastItem> Timer? _timer; Duration? _remaining; final Stopwatch _elapsed = Stopwatch(); + late _Pose _pose = widget.pose ?? _Pose.open; @override void initState() { @@ -361,6 +577,7 @@ class _FlowToastItemState extends State<_FlowToastItem> if (widget.record.dismissed) { // Dismissed before it was drawn: no entrance, straight out. _leaving = true; + widget.record.leaving = true; _exit(); return; } @@ -368,6 +585,20 @@ class _FlowToastItemState extends State<_FlowToastItem> _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 @@ -379,13 +610,13 @@ class _FlowToastItemState extends State<_FlowToastItem> super.dispose(); } - /// Armed on show, paused 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. + /// 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) return; + if (left == null || _leaving || _timer != null || widget.paused) return; if (MediaQuery.accessibleNavigationOf(context)) return; _elapsed ..reset() @@ -411,6 +642,8 @@ class _FlowToastItemState extends State<_FlowToastItem> _timer?.cancel(); _timer = null; setState(() => _leaving = true); + // The card behind moves up as this one fades, not after. + widget.stack.leaving(widget.record); _exit(); } @@ -435,48 +668,123 @@ class _FlowToastItemState extends State<_FlowToastItem> onDismiss: _leave, ); - return IgnorePointer( - ignoring: _leaving, - child: ExcludeSemantics( - excluding: _leaving, - child: MouseRegion( - onEnter: (_) => _pause(), - onExit: (_) => _arm(), - child: AnimatedBuilder( - animation: _curve, - child: card, - builder: (context, child) { - final t = _curve.value; - // A canvas transform, not an offscreen layer, so the frost - // below still samples the page through it. - return Transform.translate( - offset: Offset(0, -_settle * (1 - t)), - 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(), + // 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), + ], ), - 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/playground/lib/src/demos/toast_demo.dart b/playground/lib/src/demos/toast_demo.dart index 027833d..476ac53 100644 --- a/playground/lib/src/demos/toast_demo.dart +++ b/playground/lib/src/demos/toast_demo.dart @@ -54,9 +54,9 @@ showFlowToast( )'''; const String _stackedSnip = ''' -// Toasts stack, newest nearest the edge, three at most: a fourth -// dismisses the oldest. Each keeps its own clock, paused under the -// pointer. +// 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, From f99cd583dd892cc299b89c4f3812d155e2f9f4d3 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Thu, 3 Sep 2026 18:30:37 +0530 Subject: [PATCH 4/5] fix: keep the display insets under the stage's chrome inset --- playground/lib/src/stage.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/playground/lib/src/stage.dart b/playground/lib/src/stage.dart index 6f5598a..3291db2 100644 --- a/playground/lib/src/stage.dart +++ b/playground/lib/src/stage.dart @@ -46,12 +46,18 @@ class Stage extends StatelessWidget { // A full-surface demo (the chat) owns the whole pane; object demos // sit centred on the canvas. 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: const EdgeInsets.only(top: _chromeInset)), + data: MediaQuery.of(context).copyWith( + padding: padding.copyWith( + top: math.max(padding.top, _chromeInset), + ), + ), child: demo, ); } else { From 1f05090ec22f1f3b3d27b7bec452b743e92d5ce1 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Thu, 3 Sep 2026 18:30:37 +0530 Subject: [PATCH 5/5] fix: hold the deck's hover box open under the pointer --- lib/src/utils/flow_toast_layer.dart | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/src/utils/flow_toast_layer.dart b/lib/src/utils/flow_toast_layer.dart index 1591f54..79a04e2 100644 --- a/lib/src/utils/flow_toast_layer.dart +++ b/lib/src/utils/flow_toast_layer.dart @@ -327,6 +327,13 @@ class _FlowToastLayerState extends State<_FlowToastLayer> { 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); @@ -454,20 +461,27 @@ class _FlowToastLayerState extends State<_FlowToastLayer> { // 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. The first size is taken as is; the ones - // after move with the cards. + // 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: _deckHeight ?? 0), + 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), + onExit: (_) => setState(() { + _hovered = false; + _hoverHeight = null; + }), child: Stack( clipBehavior: Clip.none, children: [