Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ✅ |
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ✅ |
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions docs/public/_redirects
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
163 changes: 163 additions & 0 deletions docs/src/content/docs/components/toast.mdx
Original file line number Diff line number Diff line change
@@ -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:

<FlowDemo demo="toast" variant="default" height={380} title="A copied message" />

```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.

<FlowDemo demo="toast" variant="error" height={380} title="A failed upload" />

```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.

<FlowDemo demo="toast" variant="sticky" height={380} title="Up until the upload finishes" />

```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.

<FlowDemo demo="toast" variant="stacked" height={380} title="Three up; the next three push them off" />

## 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.

<FlowDemo demo="toast" variant="card" height={200} title="The card alone, in both drawn forms" />

```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` |
1 change: 1 addition & 0 deletions docs/src/content/docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,4 @@ elements and the remaining AI states are on the way.
| Chat view | <span class="badge-done">Shipped</span> |
| Side panel | <span class="badge-todo">Planned</span> |
| Modal | <span class="badge-todo">Planned</span> |
| Toast | <span class="badge-done">Shipped</span> |
2 changes: 1 addition & 1 deletion docs/src/content/docs/theming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 6 additions & 3 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -381,9 +381,12 @@ class _ChatScreenState extends State<ChatScreen> {
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
Expand Down
3 changes: 3 additions & 0 deletions lib/flow_ui.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
90 changes: 90 additions & 0 deletions lib/src/styles/flow_toast_style.dart
Original file line number Diff line number Diff line change
@@ -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,
);
}
11 changes: 11 additions & 0 deletions lib/src/theme/flow_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -58,6 +59,7 @@ class FlowTheme extends ThemeExtension<FlowTheme> {
this.pillStyle,
this.suggestionStyle,
this.threadListStyle,
this.toastStyle,
this.chatViewStyle,
});

Expand Down Expand Up @@ -117,6 +119,10 @@ class FlowTheme extends ThemeExtension<FlowTheme> {
/// 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;
Expand All @@ -137,6 +143,7 @@ class FlowTheme extends ThemeExtension<FlowTheme> {
FlowPillStyle? pillStyle,
FlowSuggestionStyle? suggestionStyle,
FlowThreadListStyle? threadListStyle,
FlowToastStyle? toastStyle,
FlowChatViewStyle? chatViewStyle,
}) {
return FlowTheme(
Expand All @@ -154,6 +161,7 @@ class FlowTheme extends ThemeExtension<FlowTheme> {
pillStyle: pillStyle ?? this.pillStyle,
suggestionStyle: suggestionStyle ?? this.suggestionStyle,
threadListStyle: threadListStyle ?? this.threadListStyle,
toastStyle: toastStyle ?? this.toastStyle,
chatViewStyle: chatViewStyle ?? this.chatViewStyle,
);
}
Expand Down Expand Up @@ -198,6 +206,9 @@ class FlowTheme extends ThemeExtension<FlowTheme> {
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),
Expand Down
Loading