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(