From d2f0ca6b8d217e6908f29a8701cfa33ec97107ad Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Tue, 1 Sep 2026 02:43:01 +0530 Subject: [PATCH 1/3] refactor: extract the asterisk painter into a shared util --- lib/src/utils/flow_asterisk_painter.dart | 52 ++++++++++++++++++++ lib/src/widgets/flow_thinking_indicator.dart | 49 +----------------- 2 files changed, 54 insertions(+), 47 deletions(-) create mode 100644 lib/src/utils/flow_asterisk_painter.dart diff --git a/lib/src/utils/flow_asterisk_painter.dart b/lib/src/utils/flow_asterisk_painter.dart new file mode 100644 index 0000000..7815156 --- /dev/null +++ b/lib/src/utils/flow_asterisk_painter.dart @@ -0,0 +1,52 @@ +import 'dart:math' as math; + +import 'package:material_ui/material_ui.dart'; + +/// The six-armed asterisk: three rounded strokes through the center. Drawn +/// rather than shipped — no SDK glyph matches the design's mark, and paint +/// stays crisp at any size and tint. +/// +/// The mark of the library's own chrome — the thinking indicator turns it, +/// the confirmation card plants it on the header — shared here rather than +/// duplicated. Internal, like `FlowCircleButton`: not exported from the +/// package barrel. +class FlowAsteriskPainter extends CustomPainter { + const FlowAsteriskPainter({required this.color, required this.strokeWidth}); + + final Color color; + final double strokeWidth; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + // A translucent ink would composite twice where the strokes cross, + // darkening the hub against text in the same ink. Flatten the mark + // into one layer and apply the ink's alpha to the whole glyph once. + final translucent = color.a < 1; + if (translucent) { + canvas.saveLayer( + (Offset.zero & size).inflate(strokeWidth), + Paint()..color = const Color(0xFFFFFFFF).withValues(alpha: color.a), + ); + paint.color = color.withValues(alpha: 1); + } + final center = size.center(Offset.zero); + final radius = (size.shortestSide - strokeWidth) / 2; + for (var i = 0; i < 3; i++) { + // Three diameters at 60° steps, starting upright so the resting mark + // has the design's vertical arm. + final angle = math.pi / 2 + math.pi * i / 3; + final delta = Offset(math.cos(angle), math.sin(angle)) * radius; + canvas.drawLine(center - delta, center + delta, paint); + } + if (translucent) canvas.restore(); + } + + @override + bool shouldRepaint(FlowAsteriskPainter oldDelegate) => + oldDelegate.color != color || oldDelegate.strokeWidth != strokeWidth; +} diff --git a/lib/src/widgets/flow_thinking_indicator.dart b/lib/src/widgets/flow_thinking_indicator.dart index df0d676..d51e537 100644 --- a/lib/src/widgets/flow_thinking_indicator.dart +++ b/lib/src/widgets/flow_thinking_indicator.dart @@ -1,8 +1,7 @@ -import 'dart:math' as math; - import 'package:material_ui/material_ui.dart'; import '../theme/flow_theme.dart'; +import '../utils/flow_asterisk_painter.dart'; import 'flow_shimmer_text.dart'; /// The thinking line shown while the assistant has not started responding: @@ -158,7 +157,7 @@ class _FlowThinkingIndicatorState extends State opacity: depth.drive(Tween(begin: 1, end: _minOpacity)), child: CustomPaint( size: Size.square(widget.size), - painter: _AsteriskPainter( + painter: FlowAsteriskPainter( color: widget.color ?? colors.onSurfaceMuted, // Proportional so the mark keeps its weight at any size. strokeWidth: widget.size / 9, @@ -196,47 +195,3 @@ class _FlowThinkingIndicatorState extends State ); } } - -/// The six-armed asterisk: three rounded strokes through the center. Drawn -/// rather than shipped — no SDK glyph matches the design's mark, and paint -/// stays crisp at any size and tint. -class _AsteriskPainter extends CustomPainter { - const _AsteriskPainter({required this.color, required this.strokeWidth}); - - final Color color; - final double strokeWidth; - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color - ..strokeWidth = strokeWidth - ..strokeCap = StrokeCap.round - ..style = PaintingStyle.stroke; - // A translucent ink would composite twice where the strokes cross, - // darkening the hub against text in the same ink. Flatten the mark - // into one layer and apply the ink's alpha to the whole glyph once. - final translucent = color.a < 1; - if (translucent) { - canvas.saveLayer( - (Offset.zero & size).inflate(strokeWidth), - Paint()..color = const Color(0xFFFFFFFF).withValues(alpha: color.a), - ); - paint.color = color.withValues(alpha: 1); - } - final center = size.center(Offset.zero); - final radius = (size.shortestSide - strokeWidth) / 2; - for (var i = 0; i < 3; i++) { - // Three diameters at 60° steps, starting upright so the resting mark - // has the design's vertical arm. - final angle = math.pi / 2 + math.pi * i / 3; - final delta = Offset(math.cos(angle), math.sin(angle)) * radius; - canvas.drawLine(center - delta, center + delta, paint); - } - if (translucent) canvas.restore(); - } - - @override - bool shouldRepaint(_AsteriskPainter oldDelegate) => - oldDelegate.color != color || oldDelegate.strokeWidth != strokeWidth; -} From cb212564df98d7da479d40c813c31fa2694f71c1 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Tue, 1 Sep 2026 02:43:01 +0530 Subject: [PATCH 2/3] feat: add FlowConfirmation, the approval card, and its message part --- AGENTS.md | 2 +- CHANGELOG.md | 6 + CLAUDE.md | 2 +- README.md | 1 + docs/public/_redirects | 1 + .../content/docs/components/confirmation.mdx | 143 ++++++ .../docs/components/message-thread.mdx | 3 +- docs/src/content/docs/roadmap.md | 2 +- docs/src/content/docs/theming.mdx | 3 +- lib/flow_ui.dart | 2 + lib/src/models/flow_message_part.dart | 36 ++ lib/src/styles/flow_confirmation_style.dart | 170 +++++++ lib/src/theme/flow_theme.dart | 11 + lib/src/widgets/flow_confirmation.dart | 457 ++++++++++++++++++ lib/src/widgets/flow_message.dart | 47 +- lib/src/widgets/flow_thread.dart | 34 ++ playground/lib/src/demo_registry.dart | 9 + .../lib/src/demos/confirmation_demo.dart | 169 +++++++ playground/lib/src/playground_item.dart | 5 + 19 files changed, 1096 insertions(+), 7 deletions(-) create mode 100644 docs/src/content/docs/components/confirmation.mdx create mode 100644 lib/src/styles/flow_confirmation_style.dart create mode 100644 lib/src/widgets/flow_confirmation.dart create mode 100644 playground/lib/src/demos/confirmation_demo.dart diff --git a/AGENTS.md b/AGENTS.md index 7eeedc0..87b1059 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Status legend: ⬜ Todo · ✅ Done | 16 | Preview | full-screen image viewer: zoom, paging | ✅ | | 17 | Tool | TBD | ⬜ | | 18 | Suggestion & Suggestion Group | plain & outlined rows; scroll, wrap, column | ✅ | -| 19 | Confirmation | default, approved, rejected | ⬜ | +| 19 | Confirmation | pending, approved, rejected; approve/reject buttons; parts render in a thread | ✅ | | 20 | Error state | failure card + retry pill; failed assistant turns render it automatically | ✅ | | 21 | Code block | built-in synchronous highlighter; languages host-extensible | ✅ | | 22 | Thinking indicator | turning, breathing asterisk + shimmer label; active & settled | ✅ | diff --git a/CHANGELOG.md b/CHANGELOG.md index bac9cc8..536c855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 0.3.0 (unreleased) +- **Confirmation** — `FlowConfirmation`, the approval card: an + asterisk-marked request with approve and reject buttons that settle + into the outcome, every label host-localized. `FlowConfirmationPart` + renders it in a thread, reporting through + `FlowThread.onConfirmationRespond`; `FlowConfirmationStyle` joins the + component styles with a `FlowTheme.confirmationStyle` default. - **Thread list** — `FlowThreadList`, the side panel's conversation history: host-labeled sections of title-only rows, single selection by id, an unread dot and pinned glyph, and a leading icon slot. Metrics diff --git a/CLAUDE.md b/CLAUDE.md index 626db94..cbf7b93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ Values come from the Flow UI Figma file. Role names follow Material 3's `ColorSc | 16 | Preview | full-screen image viewer: zoom, paging | ✅ | | 17 | Tool | TBD | ⬜ | | 18 | Suggestion & Suggestion Group | plain & outlined rows; scroll, wrap, column | ✅ | -| 19 | Confirmation | default, approved, rejected | ⬜ | +| 19 | Confirmation | pending, approved, rejected; approve/reject buttons; parts render in a thread | ✅ | | 20 | Error state | failure card + retry pill; failed assistant turns render it automatically | ✅ | | 21 | Code block | built-in synchronous highlighter; languages host-extensible | ✅ | | 22 | Thinking indicator | turning, breathing asterisk + shimmer label; active & settled | ✅ | diff --git a/README.md b/README.md index d331a8e..f04b3c9 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ | [`FlowCodeBlock`](https://flowui.stac.dev/components/code-block) | Fenced code with built-in synchronous highlighting, a header label, and a copy affordance — languages host-extensible | | [`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 | | [`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 096d8d0..a083110 100644 --- a/docs/public/_redirects +++ b/docs/public/_redirects @@ -26,6 +26,7 @@ /playground/code-block /playground/ 200 /playground/markdown /playground/ 200 /playground/error-state /playground/ 200 +/playground/confirmation /playground/ 200 /playground/add-to-chat /playground/ 200 /playground/pill /playground/ 200 /playground/attachments /playground/ 200 diff --git a/docs/src/content/docs/components/confirmation.mdx b/docs/src/content/docs/components/confirmation.mdx new file mode 100644 index 0000000..6d9bea4 --- /dev/null +++ b/docs/src/content/docs/components/confirmation.mdx @@ -0,0 +1,143 @@ +--- +title: Confirmation +description: The approval card — an asterisk-marked request with approve and reject buttons that settle into the outcome. +sidebar: + order: 18 +--- + +import FlowDemo from '../../../components/FlowDemo.astro'; + +`FlowConfirmation` is the approval card: an asterisk-marked request on a +raised card, with approve and reject buttons that settle into the +outcome. It renders state and reports intent — recording the decision, +and re-rendering the card settled, is the host's business. The package +ships no strings, so the title, the request and every button label are +host-localized; the request announces to assistive tech as a live +region, since it arrives unprompted. + +The card is *runtime chrome*, not content: the asterisk header and the +raised, hairline-and-shadow surface are its identity, the mark that +distinguishes the host's own gate — a destructive tool call, a guarded +action — from anything composed into the conversation. That is why the +glyphs are fixed and only their colors restyle. + +## Pending + +The default state, straight from the design: the warning accent, the +request, and the two buttons. A button renders only when both its label +and its callback are set — a pending card with neither pair is a +read-only notice, for chrome staged before the request is answerable. + + + +```dart title="The full anatomy" +FlowConfirmation( + title: 'Approval required', + message: 'Delete 3 files in drafts/. This cannot be undone.', + approveLabel: 'Approve', + rejectLabel: 'Reject', + onApprove: () => respond(true), + onReject: () => respond(false), +) +``` + +## Approved + +The widget holds no state: a tap reports intent, and the card settles +only when the host passes the new status back. The buttons collapse into +one row of the same footprint — so the card's height holds — and the +accent flips to success: + + + +```dart title="The settled card" +FlowConfirmation( + title: 'Approval required', + message: 'Delete 3 files in drafts/. This cannot be undone.', + status: FlowConfirmationStatus.approved, + approvedLabel: 'Approved', +) +``` + +## Rejected + +The rejected outcome carries the error accent: + + + +```dart title="The declined request" +FlowConfirmation( + title: 'Approval required', + message: 'Delete 3 files in drafts/. This cannot be undone.', + status: FlowConfirmationStatus.rejected, + rejectedLabel: 'Rejected', +) +``` + +## In a thread + +A `FlowConfirmationPart` in any turn becomes this card. The buttons hand +the message, the part and the decision back through +`FlowThread.onConfirmationRespond`, so the host can find the request +they belong to and re-render the part settled; the labels are +thread-level, since they are the same words on every card. Keep the +message's own status `complete` while the confirmation is pending — the +wait belongs to the part: + + + +```dart title="The host contract" +FlowThread( + messages: messages, + approveLabel: 'Approve', + rejectLabel: 'Reject', + approvedLabel: 'Approved', + rejectedLabel: 'Rejected', + // Typically: record the decision, run or skip the action, and + // re-render the part with the settled status. + onConfirmationRespond: (message, part, approved) => + record(message, part, approved), +) + +FlowMessageData( + id: 'a1', + role: FlowMessageRole.assistant, + parts: [ + FlowTextPart('I can clear those drafts for you.'), + FlowConfirmationPart( + title: 'Approval required', + message: 'Delete 3 files in drafts/. This cannot be undone.', + ), + ], +) +``` + +## Restyling + +`FlowConfirmationStyle` carries the card's overrides — install one on +`FlowTheme.confirmationStyle` for every card, or pass `style:` to one +widget; a widget's own style wins field by field, and nulls fall through +to the tokens. The three accents color a whole state — asterisk, title +and settled row alike — so one override recolors it coherently: + +```dart title="A different pending accent" +FlowConfirmation( + title: 'Approval required', + message: 'Delete 3 files in drafts/?', + style: const FlowConfirmationStyle(pendingColor: Color(0xFFB65C33)), +) +``` + +Beyond the style class, `padding:` and `borderRadius:` override the +card's own metrics, the per-component convention. + +## Key API + +| Member | What it does | +|---|---| +| `title` / `message` | Host-localized header and request; the message is a live region | +| `status` | `FlowConfirmationStatus.pending` shows the buttons; `approved` / `rejected` settle the card | +| `approveLabel` / `onApprove` | The filled button — renders only when both are set | +| `rejectLabel` / `onReject` | The outlined button — same rule | +| `approvedLabel` / `rejectedLabel` | The settled row's text; null leaves the outcome glyph alone | +| `style` | `FlowConfirmationStyle` overrides, merged over `FlowTheme.confirmationStyle` | diff --git a/docs/src/content/docs/components/message-thread.mdx b/docs/src/content/docs/components/message-thread.mdx index f8235f9..21452f5 100644 --- a/docs/src/content/docs/components/message-thread.mdx +++ b/docs/src/content/docs/components/message-thread.mdx @@ -97,7 +97,8 @@ FlowMessage( Message content is typed parts, not strings: a sealed `FlowMessagePart` with `FlowTextPart`, `FlowAttachmentPart`, `FlowImagePart`, and -`FlowCustomPart` subtypes (plus `FlowCodePart` and `FlowErrorPart`). +`FlowCustomPart` subtypes (plus `FlowCodePart`, `FlowErrorPart` and +`FlowConfirmationPart`). `FlowAttachmentPart` renders sent files — lifted above a user bubble as image cards, tiles elsewhere — while `FlowImagePart` is the large-format picture, an AI-generated image diff --git a/docs/src/content/docs/roadmap.md b/docs/src/content/docs/roadmap.md index f465505..58894b6 100644 --- a/docs/src/content/docs/roadmap.md +++ b/docs/src/content/docs/roadmap.md @@ -38,7 +38,7 @@ elements and the remaining AI states are on the way. | Attachment preview | Shipped | | Tool | Planned | | Suggestions | Shipped | -| Confirmation | Planned | +| Confirmation | Shipped | | Error state | Shipped | | Code block | Shipped | | Markdown | Shipped | diff --git a/docs/src/content/docs/theming.mdx b/docs/src/content/docs/theming.mdx index e710fdb..439530f 100644 --- a/docs/src/content/docs/theming.mdx +++ b/docs/src/content/docs/theming.mdx @@ -136,7 +136,8 @@ Between the tokens and a single widget sit the component styles — Material's component-theme tier. Each major widget has a `FlowXStyle` data bag of optional overrides (`FlowMenuStyle`, `FlowMarkdownStyle`, `FlowComposerStyle`, `FlowMessageStyle`, `FlowCodeBlockStyle`, -`FlowErrorStateStyle`, `FlowMessageActionsStyle`, `FlowPillStyle`, +`FlowConfirmationStyle`, `FlowErrorStateStyle`, +`FlowMessageActionsStyle`, `FlowPillStyle`, `FlowSuggestionStyle`), and the theme can carry an app-wide default for each: diff --git a/lib/flow_ui.dart b/lib/flow_ui.dart index 36cbb92..4104faa 100644 --- a/lib/flow_ui.dart +++ b/lib/flow_ui.dart @@ -30,6 +30,8 @@ export 'src/widgets/flow_code_block.dart'; export 'src/styles/flow_code_block_style.dart'; export 'src/widgets/flow_composer.dart'; export 'src/styles/flow_composer_style.dart'; +export 'src/widgets/flow_confirmation.dart'; +export 'src/styles/flow_confirmation_style.dart'; export 'src/widgets/flow_drop_target.dart'; export 'src/widgets/flow_error_state.dart'; export 'src/styles/flow_error_state_style.dart'; diff --git a/lib/src/models/flow_message_part.dart b/lib/src/models/flow_message_part.dart index 486a160..1d6288f 100644 --- a/lib/src/models/flow_message_part.dart +++ b/lib/src/models/flow_message_part.dart @@ -110,6 +110,42 @@ class FlowErrorPart extends FlowMessagePart { final bool retryable; } +/// Where a confirmation request stands. +/// +/// The host owns the transition: a tap on the card reports intent, and the +/// card renders settled only when the host passes the new status back. +enum FlowConfirmationStatus { pending, approved, rejected } + +/// A request for the user's go-ahead, rendered by a `FlowConfirmation`. +/// +/// Runtime chrome, not model content: the host — a tool gate, a +/// destructive-action guard — constructs it from facts it resolved itself, +/// and flips [status] when the user answers. The buttons' labels are +/// thread-level (`FlowThread.approveLabel` and friends), since they are +/// the same words on every card. +class FlowConfirmationPart extends FlowMessagePart { + const FlowConfirmationPart({ + this.title, + this.message, + this.status = FlowConfirmationStatus.pending, + }); + + /// Host-localized header label, e.g. 'Approval required'. Null renders + /// the asterisk alone. + final String? title; + + /// What is being asked, host-written and sentence-case. Announced as a + /// live region, since requests arrive unprompted. + final String? message; + + /// Pending shows the buttons; approved and rejected settle the card. + /// + /// Keep the message's own status `complete` while the confirmation is + /// pending — the wait belongs to this part, and a `pending` message + /// renders the thinking indicator instead of its parts. + final FlowConfirmationStatus status; +} + /// Host-defined content, rendered through a `FlowCustomPartBuilder`. class FlowCustomPart extends FlowMessagePart { const FlowCustomPart({required this.type, this.data}); diff --git a/lib/src/styles/flow_confirmation_style.dart b/lib/src/styles/flow_confirmation_style.dart new file mode 100644 index 0000000..0f65a6b --- /dev/null +++ b/lib/src/styles/flow_confirmation_style.dart @@ -0,0 +1,170 @@ +import 'package:material_ui/material_ui.dart'; + +/// Host overrides for [FlowConfirmation]'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.confirmationStyle] to +/// restyle every confirmation card — confirmation parts in a thread +/// included; a widget's own `style` wins field by field: +/// +/// ```dart +/// FlowConfirmation( +/// title: 'Approval required', +/// message: 'Delete 3 files in drafts/?', +/// style: const FlowConfirmationStyle(pendingColor: Color(0xFFB65C33)), +/// ) +/// ``` +/// +/// The three accents color the whole state — header asterisk, title and +/// the settled row alike — so one override recolors a state coherently. +@immutable +class FlowConfirmationStyle { + const FlowConfirmationStyle({ + this.backgroundColor, + this.borderColor, + this.pendingColor, + this.approvedColor, + this.rejectedColor, + this.titleStyle, + this.messageStyle, + this.approveButtonColor, + this.approveButtonForegroundColor, + this.rejectButtonColor, + this.rejectButtonBorderColor, + this.rejectButtonForegroundColor, + }); + + /// The card's fill. Defaults to `surfaceBright`. + final Color? backgroundColor; + + /// The card's hairline. Defaults to `outline`. + final Color? borderColor; + + /// The pending accent. Defaults to `warning`. + final Color? pendingColor; + + /// The approved accent. Defaults to `success`. + final Color? approvedColor; + + /// The rejected accent. Defaults to `error`. + final Color? rejectedColor; + + /// Merged over the title's default `labelSmallEmphasised` + accent + /// style. + final TextStyle? titleStyle; + + /// Merged over the message's default `bodyMedium` + `onSurface` style. + final TextStyle? messageStyle; + + /// The approve button's fill. Defaults to `inverseSurface`. + final Color? approveButtonColor; + + /// The approve button's ink. Defaults to `onInverseSurface`. + final Color? approveButtonForegroundColor; + + /// The reject button's fill. Defaults to `surfaceContainerLowest`. + final Color? rejectButtonColor; + + /// The reject button's hairline. Defaults to `outlineVariant`. + final Color? rejectButtonBorderColor; + + /// The reject button's resting ink. Defaults to `onSurfaceVariant`, + /// lifting to `onSurface` on hover. + final Color? rejectButtonForegroundColor; + + /// A copy where [other]'s fields win over this style's. + FlowConfirmationStyle merge(FlowConfirmationStyle? other) { + if (other == null) return this; + return FlowConfirmationStyle( + backgroundColor: other.backgroundColor ?? backgroundColor, + borderColor: other.borderColor ?? borderColor, + pendingColor: other.pendingColor ?? pendingColor, + approvedColor: other.approvedColor ?? approvedColor, + rejectedColor: other.rejectedColor ?? rejectedColor, + titleStyle: other.titleStyle ?? titleStyle, + messageStyle: other.messageStyle ?? messageStyle, + approveButtonColor: other.approveButtonColor ?? approveButtonColor, + approveButtonForegroundColor: + other.approveButtonForegroundColor ?? approveButtonForegroundColor, + rejectButtonColor: other.rejectButtonColor ?? rejectButtonColor, + rejectButtonBorderColor: + other.rejectButtonBorderColor ?? rejectButtonBorderColor, + rejectButtonForegroundColor: + other.rejectButtonForegroundColor ?? rejectButtonForegroundColor, + ); + } + + /// Linear interpolation, for theme transitions. A null [other] returns + /// this style unchanged. + FlowConfirmationStyle lerp(FlowConfirmationStyle? other, double t) { + if (other == null) return this; + return FlowConfirmationStyle( + backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t), + borderColor: Color.lerp(borderColor, other.borderColor, t), + pendingColor: Color.lerp(pendingColor, other.pendingColor, t), + approvedColor: Color.lerp(approvedColor, other.approvedColor, t), + rejectedColor: Color.lerp(rejectedColor, other.rejectedColor, t), + titleStyle: TextStyle.lerp(titleStyle, other.titleStyle, t), + messageStyle: TextStyle.lerp(messageStyle, other.messageStyle, t), + approveButtonColor: Color.lerp( + approveButtonColor, + other.approveButtonColor, + t, + ), + approveButtonForegroundColor: Color.lerp( + approveButtonForegroundColor, + other.approveButtonForegroundColor, + t, + ), + rejectButtonColor: Color.lerp( + rejectButtonColor, + other.rejectButtonColor, + t, + ), + rejectButtonBorderColor: Color.lerp( + rejectButtonBorderColor, + other.rejectButtonBorderColor, + t, + ), + rejectButtonForegroundColor: Color.lerp( + rejectButtonForegroundColor, + other.rejectButtonForegroundColor, + t, + ), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is FlowConfirmationStyle && + other.backgroundColor == backgroundColor && + other.borderColor == borderColor && + other.pendingColor == pendingColor && + other.approvedColor == approvedColor && + other.rejectedColor == rejectedColor && + other.titleStyle == titleStyle && + other.messageStyle == messageStyle && + other.approveButtonColor == approveButtonColor && + other.approveButtonForegroundColor == approveButtonForegroundColor && + other.rejectButtonColor == rejectButtonColor && + other.rejectButtonBorderColor == rejectButtonBorderColor && + other.rejectButtonForegroundColor == rejectButtonForegroundColor; + } + + @override + int get hashCode => Object.hash( + backgroundColor, + borderColor, + pendingColor, + approvedColor, + rejectedColor, + titleStyle, + messageStyle, + approveButtonColor, + approveButtonForegroundColor, + rejectButtonColor, + rejectButtonBorderColor, + rejectButtonForegroundColor, + ); +} diff --git a/lib/src/theme/flow_theme.dart b/lib/src/theme/flow_theme.dart index dd6e78b..2c3dbd3 100644 --- a/lib/src/theme/flow_theme.dart +++ b/lib/src/theme/flow_theme.dart @@ -3,6 +3,7 @@ import 'package:material_ui/material_ui.dart'; import '../styles/flow_chat_view_style.dart'; import '../styles/flow_code_block_style.dart'; import '../styles/flow_composer_style.dart'; +import '../styles/flow_confirmation_style.dart'; import '../styles/flow_error_state_style.dart'; import '../styles/flow_markdown_style.dart'; import '../styles/flow_menu_style.dart'; @@ -51,6 +52,7 @@ class FlowTheme extends ThemeExtension { this.menuStyle, this.markdownStyle, this.codeBlockStyle, + this.confirmationStyle, this.errorStateStyle, this.messageActionsStyle, this.pillStyle, @@ -96,6 +98,10 @@ class FlowTheme extends ThemeExtension { /// code parts included. final FlowCodeBlockStyle? codeBlockStyle; + /// App-wide default for every `FlowConfirmation` — confirmation parts + /// in a thread included. + final FlowConfirmationStyle? confirmationStyle; + /// App-wide default for every `FlowErrorState` — failed turns included. final FlowErrorStateStyle? errorStateStyle; @@ -125,6 +131,7 @@ class FlowTheme extends ThemeExtension { FlowMenuStyle? menuStyle, FlowMarkdownStyle? markdownStyle, FlowCodeBlockStyle? codeBlockStyle, + FlowConfirmationStyle? confirmationStyle, FlowErrorStateStyle? errorStateStyle, FlowMessageActionsStyle? messageActionsStyle, FlowPillStyle? pillStyle, @@ -141,6 +148,7 @@ class FlowTheme extends ThemeExtension { menuStyle: menuStyle ?? this.menuStyle, markdownStyle: markdownStyle ?? this.markdownStyle, codeBlockStyle: codeBlockStyle ?? this.codeBlockStyle, + confirmationStyle: confirmationStyle ?? this.confirmationStyle, errorStateStyle: errorStateStyle ?? this.errorStateStyle, messageActionsStyle: messageActionsStyle ?? this.messageActionsStyle, pillStyle: pillStyle ?? this.pillStyle, @@ -172,6 +180,9 @@ class FlowTheme extends ThemeExtension { codeBlockStyle: codeBlockStyle == null ? other.codeBlockStyle : codeBlockStyle!.lerp(other.codeBlockStyle, t), + confirmationStyle: confirmationStyle == null + ? other.confirmationStyle + : confirmationStyle!.lerp(other.confirmationStyle, t), errorStateStyle: errorStateStyle == null ? other.errorStateStyle : errorStateStyle!.lerp(other.errorStateStyle, t), diff --git a/lib/src/widgets/flow_confirmation.dart b/lib/src/widgets/flow_confirmation.dart new file mode 100644 index 0000000..0f892ee --- /dev/null +++ b/lib/src/widgets/flow_confirmation.dart @@ -0,0 +1,457 @@ +import 'package:material_ui/material_ui.dart'; + +import '../models/flow_message_part.dart'; +import '../styles/flow_confirmation_style.dart'; +import '../theme/flow_colors.dart'; +import '../theme/flow_theme.dart'; +import '../theme/flow_typography.dart'; +import '../utils/flow_asterisk_painter.dart'; + +/// The approval card: an asterisk-marked request on a raised card, with +/// approve and reject buttons that settle into the outcome. +/// +/// ```dart +/// FlowConfirmation( +/// title: 'Approval required', +/// message: 'Delete 3 files in drafts/. This cannot be undone.', +/// approveLabel: 'Approve', +/// rejectLabel: 'Reject', +/// onApprove: () => respond(true), +/// onReject: () => respond(false), +/// ) +/// ``` +/// +/// In a thread this renders on its own: a `FlowConfirmationPart` in any +/// turn becomes this card, its buttons reporting through +/// `FlowThread.onConfirmationRespond`. Standalone it serves hosts that +/// gate an action outside a conversation. +/// +/// The widget holds no state: a tap reports intent, and the card renders +/// settled only when the host re-renders with the new [status] — pending +/// shows the buttons, approved and rejected replace them with one settled +/// row of the same footprint, so the card's height holds. A pending card +/// with no button pair is a read-only notice, for chrome staged before +/// the request is answerable. +/// +/// The asterisk header is the card's identity — the mark that +/// distinguishes the runtime's own chrome from content — so the glyphs +/// are not swappable; restyle their colors through +/// [FlowConfirmationStyle]. The package ships no strings: every label is +/// host-localized. +class FlowConfirmation extends StatelessWidget { + const FlowConfirmation({ + super.key, + this.title, + this.message, + this.status = FlowConfirmationStatus.pending, + this.approveLabel, + this.rejectLabel, + this.approvedLabel, + this.rejectedLabel, + this.onApprove, + this.onReject, + this.padding, + this.borderRadius, + this.style, + }); + + /// Host-localized header label, e.g. 'Approval required'. Stays on the + /// card in every state — the accent, not the words, carries the + /// outcome. Null renders the asterisk alone. + final String? title; + + /// The request, host-written and sentence-case. Announced to assistive + /// tech as a live region, since requests arrive unprompted. + final String? message; + + /// Drives the whole card: pending shows the buttons, approved and + /// rejected replace them with the settled row. + final FlowConfirmationStatus status; + + /// Host-localized label and accessible name of the filled button. The + /// button renders only when both this and [onApprove] are set. + final String? approveLabel; + + /// Host-localized label and accessible name of the outlined button. The + /// button renders only when both this and [onReject] are set. + final String? rejectLabel; + + /// Host-localized text of the settled row when approved, e.g. + /// 'Approved'. Null shows the check alone — and announces nothing, so + /// pass one where the outcome should be heard. + final String? approvedLabel; + + /// Host-localized text of the settled row when rejected, e.g. + /// 'Rejected'. Null shows the cross alone. + final String? rejectedLabel; + + /// Approve intent. Null hides the button. + final VoidCallback? onApprove; + + /// Reject intent. Null hides the button. + final VoidCallback? onReject; + + /// Inside the card. Defaults to the design's 12 sides, 10 top, 12 + /// bottom. + final EdgeInsetsGeometry? padding; + + /// The card's corner. Defaults to the design's 12. + final BorderRadius? borderRadius; + + /// Per-instance restyling, merged over [FlowTheme.confirmationStyle]'s + /// fields; nulls fall through to the theme tokens. + final FlowConfirmationStyle? style; + + /// The card: the raised surface's 12px corner, the faint hairline and + /// the ambient 2%-ink shadow — the same lift as the composer and the + /// menu card, because this is chrome, not content. + static const BorderRadius _radius = BorderRadius.all(Radius.circular(12)); + static const EdgeInsetsGeometry _cardPadding = EdgeInsets.fromLTRB( + 12, + 10, + 12, + 12, + ); + static const double _shadowBlur = 12; + + /// The header: a 16px asterisk a 4px gap from the title, centred on the + /// title's first line. + static const double _glyphSize = 16; + static const double _glyphGap = 4; + + /// Gaps: header to the request, content to the actions or settled row. + static const double _messageGap = 6; + static const double _actionsGap = 12; + + /// The buttons' row: right-aligned, 10 apart, wrapping onto an 8px-gapped + /// second run when two long localized labels outgrow a phone bubble. + static const double _buttonGap = 10; + static const double _buttonRunGap = 8; + + /// The approve button's hover wash — its own ink at 8%, since the + /// translucent surface washes vanish on the inverse fill. + static const double _approveHoverOpacity = 0.08; + + @override + Widget build(BuildContext context) { + final colors = context.flowColors; + final typography = context.flowTypography; + + final title = this.title; + final message = this.message; + + final effective = + context.flowTheme.confirmationStyle?.merge(style) ?? style; + + // One accent per state: the pending warning, then success or error. + // It colors the asterisk, the title and the settled row alike. + final accent = switch (status) { + FlowConfirmationStatus.pending => + effective?.pendingColor ?? colors.warning, + FlowConfirmationStatus.approved => + effective?.approvedColor ?? colors.success, + FlowConfirmationStatus.rejected => + effective?.rejectedColor ?? colors.error, + }; + + final titleStyle = typography.labelSmallEmphasised + .copyWith(color: accent) + .merge(effective?.titleStyle); + + Widget? titleLabel; + if (title != null) { + titleLabel = Text(title, style: titleStyle); + if (message == null) { + // The title is all the card says, and requests arrive unprompted: + // announce it. + titleLabel = Semantics(liveRegion: true, child: titleLabel); + } + } + + // The asterisk centres on the title's first line — the error card's + // idiom, so a wrapped title keeps the glyph beside its opening line. + final firstLineHeight = + (titleStyle.fontSize ?? _glyphSize) * (titleStyle.height ?? 1); + + return Container( + padding: padding ?? _cardPadding, + decoration: BoxDecoration( + color: effective?.backgroundColor ?? colors.surfaceBright, + borderRadius: borderRadius ?? _radius, + border: Border.all(color: effective?.borderColor ?? colors.outline), + boxShadow: [BoxShadow(color: colors.shadow, blurRadius: _shadowBlur)], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: firstLineHeight, + child: Center( + child: CustomPaint( + size: const Size.square(_glyphSize), + painter: FlowAsteriskPainter( + color: accent, + strokeWidth: _glyphSize / 9, + ), + ), + ), + ), + if (titleLabel != null) ...[ + const SizedBox(width: _glyphGap), + Flexible(child: titleLabel), + ], + ], + ), + if (message != null) + Padding( + padding: const EdgeInsets.only(top: _messageGap), + child: Semantics( + liveRegion: true, + child: Text( + message, + style: typography.bodyMedium + .copyWith(color: colors.onSurface) + .merge(effective?.messageStyle), + ), + ), + ), + ..._buildFooter(colors, typography, effective, accent), + ], + ), + ); + } + + /// The card's closing row: the buttons while pending, the settled chip + /// after — or nothing, when a pending card has no button pair. + List _buildFooter( + FlowColors colors, + FlowTypography typography, + FlowConfirmationStyle? effective, + Color accent, + ) { + Widget footer; + if (status == FlowConfirmationStatus.pending) { + final approveLabel = this.approveLabel; + final rejectLabel = this.rejectLabel; + final approveForeground = + effective?.approveButtonForegroundColor ?? colors.onInverseSurface; + final buttons = [ + if (rejectLabel != null && onReject != null) + _ConfirmationButton( + label: rejectLabel, + onTap: onReject!, + textStyle: typography.labelMediumEmphasised, + background: + effective?.rejectButtonColor ?? colors.surfaceContainerLowest, + foreground: + effective?.rejectButtonForegroundColor ?? + colors.onSurfaceVariant, + hoverForeground: colors.onSurface, + hoverColor: colors.surfaceContainerLow, + borderColor: + effective?.rejectButtonBorderColor ?? colors.outlineVariant, + ), + if (approveLabel != null && onApprove != null) + _ConfirmationButton( + label: approveLabel, + onTap: onApprove!, + textStyle: FlowTypography.recut( + typography.labelMedium, + fontWeight: FontWeight.w600, + ), + background: effective?.approveButtonColor ?? colors.inverseSurface, + foreground: approveForeground, + hoverForeground: approveForeground, + hoverColor: approveForeground.withValues( + alpha: _approveHoverOpacity, + ), + ), + ]; + if (buttons.isEmpty) return const []; + footer = SizedBox( + width: double.infinity, + child: Wrap( + alignment: WrapAlignment.end, + spacing: _buttonGap, + runSpacing: _buttonRunGap, + children: buttons, + ), + ); + } else { + final approved = status == FlowConfirmationStatus.approved; + footer = Align( + alignment: AlignmentDirectional.centerEnd, + child: _SettledRow( + approved: approved, + label: approved ? approvedLabel : rejectedLabel, + accent: accent, + textStyle: typography.labelMediumEmphasised, + ), + ); + } + return [ + Padding( + padding: const EdgeInsets.only(top: _actionsGap), + child: footer, + ), + ]; + } +} + +/// The card's action button in either cut — filled approve, outlined +/// reject — private until the design system's Button lands and absorbs +/// it, like the error card's retry pill. +class _ConfirmationButton extends StatefulWidget { + const _ConfirmationButton({ + required this.label, + required this.onTap, + required this.textStyle, + required this.background, + required this.foreground, + required this.hoverForeground, + required this.hoverColor, + this.borderColor, + }); + + final String label; + final VoidCallback onTap; + final TextStyle textStyle; + final Color background; + final Color foreground; + final Color hoverForeground; + final Color hoverColor; + + /// The outlined cut's hairline; null renders the filled cut. + final Color? borderColor; + + @override + State<_ConfirmationButton> createState() => _ConfirmationButtonState(); +} + +class _ConfirmationButtonState extends State<_ConfirmationButton> { + /// The design's button: 32 tall on an 8px corner, padded 12 — the + /// retry pill's frame. + static const double _height = 32; + static const BorderRadius _radius = BorderRadius.all(Radius.circular(8)); + static const EdgeInsetsGeometry _padding = EdgeInsets.symmetric( + horizontal: 12, + ); + + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final borderColor = widget.borderColor; + final shape = RoundedRectangleBorder( + borderRadius: _radius, + side: borderColor == null + ? BorderSide.none + : BorderSide(color: borderColor), + ); + + final button = Material( + color: widget.background, + shape: shape, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: widget.onTap, + onHover: (value) => setState(() => _hovered = value), + customBorder: shape, + hoverColor: widget.hoverColor, + child: SizedBox( + height: _height, + child: Padding( + padding: _padding, + child: Center( + widthFactor: 1, + child: Text( + widget.label, + style: widget.textStyle.copyWith( + color: _hovered ? widget.hoverForeground : widget.foreground, + ), + ), + ), + ), + ), + ), + ); + + // Excluding the subtree keeps the label from reading twice, but it + // drops the InkWell's tap action with it — the node re-owns + // activation or assistive tech can announce the button yet not tap + // it. + return Semantics( + button: true, + label: widget.label, + excludeSemantics: true, + onTap: widget.onTap, + child: button, + ); + } +} + +/// The settled outcome: the pressed button's footprint, kept — a check or +/// cross with its label on the accent's wash, non-interactive. +class _SettledRow extends StatelessWidget { + const _SettledRow({ + required this.approved, + required this.label, + required this.accent, + required this.textStyle, + }); + + final bool approved; + final String? label; + final Color accent; + final TextStyle textStyle; + + /// The button's frame, and the accent at the status containers' 6% — + /// so the default wash equals `successContainer` / `errorContainer` + /// and a restyled accent still gets a matching fill. + 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 _fillOpacity = 0.06; + static const double _glyphSize = 14; + static const double _glyphGap = 6; + + @override + Widget build(BuildContext context) { + final label = this.label; + return Container( + height: _height, + padding: _padding, + decoration: BoxDecoration( + color: accent.withValues(alpha: _fillOpacity), + borderRadius: _radius, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ExcludeSemantics( + child: Icon( + approved ? Icons.check : Icons.close, + size: _glyphSize, + color: accent, + ), + ), + if (label != null) ...[ + const SizedBox(width: _glyphGap), + // The outcome lands unprompted too — announce the flip. + Semantics( + liveRegion: true, + child: Text(label, style: textStyle.copyWith(color: accent)), + ), + ], + ], + ), + ); + } +} diff --git a/lib/src/widgets/flow_message.dart b/lib/src/widgets/flow_message.dart index 8dee690..1a3b097 100644 --- a/lib/src/widgets/flow_message.dart +++ b/lib/src/widgets/flow_message.dart @@ -10,6 +10,7 @@ import '../utils/flow_shimmer_sweep.dart'; import 'flow_attachment_group.dart'; import 'flow_attachment_preview.dart'; import 'flow_code_block.dart'; +import 'flow_confirmation.dart'; import 'flow_error_state.dart'; import 'flow_markdown.dart'; import 'flow_streaming_text.dart'; @@ -38,6 +39,8 @@ typedef FlowCustomPartBuilder = /// turn keeps its parts in normal ink and closes with a [FlowErrorState] /// card — the message's own [FlowErrorPart], or a default one when the host /// supplies none; an error user bubble recolors to the error container. +/// A [FlowConfirmationPart] renders a [FlowConfirmation] card, its buttons +/// reporting through [onConfirmationRespond]. class FlowMessage extends StatelessWidget { const FlowMessage( this.message, { @@ -53,6 +56,11 @@ class FlowMessage extends StatelessWidget { this.onRetry, this.errorTitle, this.retryLabel, + this.onConfirmationRespond, + this.approveLabel, + this.rejectLabel, + this.approvedLabel, + this.rejectedLabel, this.leading, this.footer, this.maxBubbleWidthFraction = 0.75, @@ -119,6 +127,21 @@ class FlowMessage extends StatelessWidget { /// the pill glyph-only. final String? retryLabel; + /// Approve/reject intent from a [FlowConfirmationPart]'s card, handed + /// the part and the decision. Null renders every pending card without + /// buttons — a read-only notice. + final void Function(FlowConfirmationPart part, bool approved)? + onConfirmationRespond; + + /// Host-localized labels for the confirmation cards — the approve and + /// reject buttons, and the settled row's approved and rejected text. A + /// null button label hides that button; a null settled label leaves the + /// outcome glyph alone. + final String? approveLabel; + final String? rejectLabel; + final String? approvedLabel; + final String? rejectedLabel; + /// Slot beside the content, e.g. an avatar. final Widget? leading; @@ -354,11 +377,13 @@ class FlowMessage extends StatelessWidget { textAlign: TextAlign.center, ), // System messages are centered notices; attachments, images, - // code and failures belong to user and assistant turns. + // code, failures and confirmations belong to user and + // assistant turns. FlowAttachmentPart() || FlowImagePart() || FlowCodePart() || - FlowErrorPart() => const SizedBox.shrink(), + FlowErrorPart() || + FlowConfirmationPart() => const SizedBox.shrink(), FlowCustomPart() => customPartBuilder?.call(context, message, part) ?? const SizedBox.shrink(), @@ -523,6 +548,7 @@ class FlowMessage extends StatelessWidget { .copyWith(color: foreground) .merge(textStyle); final onCodeCopy = this.onCodeCopy; + final onConfirmationRespond = this.onConfirmationRespond; // Only text parts get the streaming reveal; a message that ends in a // code part streams its code without one (FlowCodeBlock renders each @@ -599,6 +625,23 @@ class FlowMessage extends StatelessWidget { retryLabel: retryLabel, onRetry: retryable ? onRetry : null, ), + // Bound whole rather than destructured: the closures hand the + // part back so the host can find the request it belongs to. + FlowConfirmationPart confirmation => FlowConfirmation( + title: confirmation.title, + message: confirmation.message, + status: confirmation.status, + approveLabel: approveLabel, + rejectLabel: rejectLabel, + approvedLabel: approvedLabel, + rejectedLabel: rejectedLabel, + onApprove: onConfirmationRespond == null + ? null + : () => onConfirmationRespond(confirmation, true), + onReject: onConfirmationRespond == null + ? null + : () => onConfirmationRespond(confirmation, false), + ), FlowCustomPart() => customPartBuilder?.call(context, message, part), }; if (child == null) continue; diff --git a/lib/src/widgets/flow_thread.dart b/lib/src/widgets/flow_thread.dart index c999849..b33baf1 100644 --- a/lib/src/widgets/flow_thread.dart +++ b/lib/src/widgets/flow_thread.dart @@ -30,6 +30,11 @@ class FlowThread extends StatefulWidget { this.onRetry, this.errorTitle, this.retryLabel, + this.onConfirmationRespond, + this.approveLabel, + this.rejectLabel, + this.approvedLabel, + this.rejectedLabel, this.controller, this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag, this.padding, @@ -90,6 +95,26 @@ class FlowThread extends StatefulWidget { /// the pill glyph-only. final String? retryLabel; + /// Approve/reject intent from a [FlowConfirmationPart]'s card, handed + /// the message, the part and the decision so the host can find the + /// request it belongs to and re-render the part settled. Forwarded to + /// each [FlowMessage]; null renders every pending card without buttons. + final void Function( + FlowMessageData message, + FlowConfirmationPart part, + bool approved, + )? + onConfirmationRespond; + + /// Host-localized labels for the thread's confirmation cards — the + /// approve and reject buttons, and the settled row's approved and + /// rejected text. A null button label hides that button; a null settled + /// label leaves the outcome glyph alone. + final String? approveLabel; + final String? rejectLabel; + final String? approvedLabel; + final String? rejectedLabel; + /// Optional external scroll controller. final ScrollController? controller; @@ -251,6 +276,7 @@ class _FlowThreadState extends State { final gap = widget.itemSpacing ?? _defaultGap; final onAttachmentTap = widget.onAttachmentTap; final onRetry = widget.onRetry; + final onConfirmationRespond = widget.onConfirmationRespond; final onLinkTap = widget.onLinkTap; final messages = widget.messages; _syncStreaming(messages); @@ -300,6 +326,14 @@ class _FlowThreadState extends State { onRetry: onRetry == null ? null : () => onRetry(message), errorTitle: widget.errorTitle, retryLabel: widget.retryLabel, + onConfirmationRespond: onConfirmationRespond == null + ? null + : (part, approved) => + onConfirmationRespond(message, part, approved), + approveLabel: widget.approveLabel, + rejectLabel: widget.rejectLabel, + approvedLabel: widget.approvedLabel, + rejectedLabel: widget.rejectedLabel, charactersPerSecond: widget.charactersPerSecond, thinkingLabel: widget.thinkingLabel, footer: widget.messageFooter?.call(message), diff --git a/playground/lib/src/demo_registry.dart b/playground/lib/src/demo_registry.dart index faf0784..5e9b75b 100644 --- a/playground/lib/src/demo_registry.dart +++ b/playground/lib/src/demo_registry.dart @@ -4,6 +4,7 @@ import 'demos/add_to_chat_demo.dart'; import 'demos/attachments_demo.dart'; import 'demos/code_block_demo.dart'; import 'demos/composer_demo.dart'; +import 'demos/confirmation_demo.dart'; import 'demos/error_state_demo.dart'; import 'demos/full_chat_demo.dart'; import 'demos/greeting_demo.dart'; @@ -38,6 +39,7 @@ Widget demoFor(PlaygroundItem item, {String? variant}) { PlaygroundItem.codeBlock => CodeBlockDemo(key: key, variant: variant), PlaygroundItem.markdown => MarkdownDemo(key: key, variant: variant), PlaygroundItem.errorState => ErrorStateDemo(key: key, variant: variant), + PlaygroundItem.confirmation => ConfirmationDemo(key: key, variant: variant), PlaygroundItem.addToChat => AddToChatDemo(key: key), PlaygroundItem.pill => PillDemo(key: key, variant: variant), PlaygroundItem.attachments => AttachmentsDemo(key: key, variant: variant), @@ -98,6 +100,12 @@ List<(String, String)> variantsFor(PlaygroundItem item) { ('minimal', 'Minimal'), ('thread', 'Failed turn'), ], + PlaygroundItem.confirmation => const [ + ('pending', 'Pending'), + ('approved', 'Approved'), + ('rejected', 'Rejected'), + ('thread', 'In a thread'), + ], PlaygroundItem.pill => const [ ('default', 'Default'), ('icon', 'Icon only'), @@ -165,6 +173,7 @@ String snippetFor(PlaygroundItem item, {String? variant}) { PlaygroundItem.codeBlock => codeBlockSnippet(variant), PlaygroundItem.markdown => markdownSnippet(variant), PlaygroundItem.errorState => errorStateSnippet(variant), + PlaygroundItem.confirmation => confirmationSnippet(variant), PlaygroundItem.addToChat => addToChatSnippet, PlaygroundItem.pill => pillSnippet(variant), PlaygroundItem.attachments => attachmentsSnippet(variant), diff --git a/playground/lib/src/demos/confirmation_demo.dart b/playground/lib/src/demos/confirmation_demo.dart new file mode 100644 index 0000000..c6dc293 --- /dev/null +++ b/playground/lib/src/demos/confirmation_demo.dart @@ -0,0 +1,169 @@ +import 'package:flow_ui/flow_ui.dart'; +import 'package:material_ui/material_ui.dart'; + +String confirmationSnippet([String? variant]) => switch (variant) { + 'approved' => _approvedSnip, + 'rejected' => _rejectedSnip, + 'thread' => _threadSnip, + _ => _pendingSnip, +}; + +const String _pendingSnip = ''' +// The card renders state and reports intent; recording the decision — +// and re-rendering with the new status — is the host's business. +FlowConfirmation( + title: 'Approval required', + message: 'Delete 3 files in drafts/. This cannot be undone.', + approveLabel: 'Approve', + rejectLabel: 'Reject', + onApprove: () => respond(true), + onReject: () => respond(false), +)'''; + +const String _approvedSnip = ''' +// A settled card: the accent flips to success and the buttons collapse +// into the outcome's row, in the host's words. +FlowConfirmation( + title: 'Approval required', + message: 'Delete 3 files in drafts/. This cannot be undone.', + status: FlowConfirmationStatus.approved, + approvedLabel: 'Approved', +)'''; + +const String _rejectedSnip = ''' +// The rejected outcome carries the error accent. +FlowConfirmation( + title: 'Approval required', + message: 'Delete 3 files in drafts/. This cannot be undone.', + status: FlowConfirmationStatus.rejected, + rejectedLabel: 'Rejected', +)'''; + +const String _threadSnip = ''' +// In a thread the card renders on its own: a FlowConfirmationPart in +// any turn becomes it, and the buttons hand the message, the part and +// the decision back so the host can settle the request. +FlowThread( + messages: messages, + approveLabel: 'Approve', + rejectLabel: 'Reject', + approvedLabel: 'Approved', + rejectedLabel: 'Rejected', + onConfirmationRespond: (message, part, approved) => + record(message, part, approved), +) + +FlowMessageData( + id: 'a1', + role: FlowMessageRole.assistant, + parts: [ + FlowTextPart('I can clear those drafts for you.'), + FlowConfirmationPart( + title: 'Approval required', + message: 'Delete 3 files in drafts/. This cannot be undone.', + ), + ], +)'''; + +const String _requestMessage = + 'Delete 3 files in drafts/. This cannot be undone.'; + +/// Stage demo for `FlowConfirmation` — the live pending card whose +/// buttons actually settle it, the two settled forms, and a turn in a +/// thread where the decision lands the way a host would record it. +class ConfirmationDemo extends StatefulWidget { + const ConfirmationDemo({super.key, this.variant}); + + final String? variant; + + @override + State createState() => _ConfirmationDemoState(); +} + +class _ConfirmationDemoState extends State { + FlowConfirmationStatus _status = FlowConfirmationStatus.pending; + + void _respond(bool approved) { + setState( + () => _status = approved + ? FlowConfirmationStatus.approved + : FlowConfirmationStatus.rejected, + ); + } + + /// Thread variant: the decision settles the card in place, and the + /// reply continues past it — the shape of a tool gate in a real host. + List get _messages => [ + FlowMessageData.text( + id: 'u1', + role: FlowMessageRole.user, + text: 'Clear out my drafts folder.', + ), + FlowMessageData( + id: 'a1', + role: FlowMessageRole.assistant, + parts: [ + const FlowTextPart('I can clear those drafts for you.'), + FlowConfirmationPart( + title: 'Approval required', + message: _requestMessage, + status: _status, + ), + if (_status == FlowConfirmationStatus.approved) + const FlowTextPart('Done — the drafts folder is empty.'), + if (_status == FlowConfirmationStatus.rejected) + const FlowTextPart('Understood — I left the drafts untouched.'), + ], + ), + ]; + + @override + Widget build(BuildContext context) { + final child = switch (widget.variant) { + 'approved' => const FlowConfirmation( + title: 'Approval required', + message: _requestMessage, + status: FlowConfirmationStatus.approved, + approvedLabel: 'Approved', + ), + 'rejected' => const FlowConfirmation( + title: 'Approval required', + message: _requestMessage, + status: FlowConfirmationStatus.rejected, + rejectedLabel: 'Rejected', + ), + 'thread' => SizedBox( + height: 420, + child: FlowThread( + messages: _messages, + approveLabel: 'Approve', + rejectLabel: 'Reject', + approvedLabel: 'Approved', + rejectedLabel: 'Rejected', + onConfirmationRespond: (message, part, approved) => + _respond(approved), + ), + ), + _ => FlowConfirmation( + title: 'Approval required', + message: _requestMessage, + status: _status, + approveLabel: 'Approve', + rejectLabel: 'Reject', + approvedLabel: 'Approved', + rejectedLabel: 'Rejected', + onApprove: () => _respond(true), + onReject: () => _respond(false), + ), + }; + + return Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: widget.variant == 'thread' ? 560 : 480, + ), + child: child, + ), + ); + } +} diff --git a/playground/lib/src/playground_item.dart b/playground/lib/src/playground_item.dart index c89e217..a1b678f 100644 --- a/playground/lib/src/playground_item.dart +++ b/playground/lib/src/playground_item.dart @@ -35,6 +35,11 @@ enum PlaygroundItem { PhosphorIconsRegular.warningCircle, 'flow_error_state.dart', ), + confirmation( + 'Confirmation', + PhosphorIconsRegular.shieldCheck, + 'flow_confirmation.dart', + ), addToChat( 'Add to Chat', PhosphorIconsRegular.plus, From 748579dea9e4c09e5d0317acff839e6e26012637 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Tue, 1 Sep 2026 21:36:13 +0530 Subject: [PATCH 3/3] fix: set the confirmation card's text in the ink ramp for AA contrast --- .../content/docs/components/confirmation.mdx | 7 +++++-- lib/src/styles/flow_confirmation_style.dart | 11 ++++++---- lib/src/widgets/flow_confirmation.dart | 20 +++++++++++++++---- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/docs/src/content/docs/components/confirmation.mdx b/docs/src/content/docs/components/confirmation.mdx index 6d9bea4..d1b5682 100644 --- a/docs/src/content/docs/components/confirmation.mdx +++ b/docs/src/content/docs/components/confirmation.mdx @@ -117,8 +117,11 @@ FlowMessageData( `FlowConfirmationStyle` carries the card's overrides — install one on `FlowTheme.confirmationStyle` for every card, or pass `style:` to one widget; a widget's own style wins field by field, and nulls fall through -to the tokens. The three accents color a whole state — asterisk, title -and settled row alike — so one override recolors it coherently: +to the tokens. The three accents color a state's marks — the asterisk, +the settled glyph and its wash — so one override recolors it coherently. +The title and the settled label stay in the ink ramp: the light accents +fall short of WCAG AA for text on the card, so the words keep their +contrast and the accent signals beside them: ```dart title="A different pending accent" FlowConfirmation( diff --git a/lib/src/styles/flow_confirmation_style.dart b/lib/src/styles/flow_confirmation_style.dart index 0f65a6b..2810315 100644 --- a/lib/src/styles/flow_confirmation_style.dart +++ b/lib/src/styles/flow_confirmation_style.dart @@ -15,8 +15,11 @@ import 'package:material_ui/material_ui.dart'; /// ) /// ``` /// -/// The three accents color the whole state — header asterisk, title and -/// the settled row alike — so one override recolors a state coherently. +/// The three accents color a state's marks — the header asterisk, the +/// settled glyph and its wash — so one override recolors a state +/// coherently. The title and the settled label read in the ink ramp, not +/// the accent: the light accents fall short of WCAG AA for text on the +/// card, so the words stay legible and the accent signals beside them. @immutable class FlowConfirmationStyle { const FlowConfirmationStyle({ @@ -49,8 +52,8 @@ class FlowConfirmationStyle { /// The rejected accent. Defaults to `error`. final Color? rejectedColor; - /// Merged over the title's default `labelSmallEmphasised` + accent - /// style. + /// Merged over the title's default `labelSmallEmphasised` + + /// `onSurfaceVariant` style. final TextStyle? titleStyle; /// Merged over the message's default `bodyMedium` + `onSurface` style. diff --git a/lib/src/widgets/flow_confirmation.dart b/lib/src/widgets/flow_confirmation.dart index 0f892ee..b2906d4 100644 --- a/lib/src/widgets/flow_confirmation.dart +++ b/lib/src/widgets/flow_confirmation.dart @@ -144,7 +144,10 @@ class FlowConfirmation extends StatelessWidget { context.flowTheme.confirmationStyle?.merge(style) ?? style; // One accent per state: the pending warning, then success or error. - // It colors the asterisk, the title and the settled row alike. + // It colors the marks — the asterisk, the settled glyph and its wash — + // never the words: the light accents sit at 2.5–4.5:1 on the card, + // under WCAG AA for text, so the title and the settled label read in + // the ink ramp and the accent carries the state beside them. final accent = switch (status) { FlowConfirmationStatus.pending => effective?.pendingColor ?? colors.warning, @@ -155,7 +158,7 @@ class FlowConfirmation extends StatelessWidget { }; final titleStyle = typography.labelSmallEmphasised - .copyWith(color: accent) + .copyWith(color: colors.onSurfaceVariant) .merge(effective?.titleStyle); Widget? titleLabel; @@ -290,6 +293,7 @@ class FlowConfirmation extends StatelessWidget { approved: approved, label: approved ? approvedLabel : rejectedLabel, accent: accent, + foreground: colors.onSurface, textStyle: typography.labelMediumEmphasised, ), ); @@ -396,18 +400,26 @@ class _ConfirmationButtonState extends State<_ConfirmationButton> { } /// The settled outcome: the pressed button's footprint, kept — a check or -/// cross with its label on the accent's wash, non-interactive. +/// cross in the accent beside the label in the ink, on the accent's wash, +/// non-interactive. class _SettledRow extends StatelessWidget { const _SettledRow({ required this.approved, required this.label, required this.accent, + required this.foreground, required this.textStyle, }); final bool approved; final String? label; + + /// The glyph and the wash. final Color accent; + + /// The label's ink — the full-strength `onSurface`, since the light + /// accents fall short of AA on their own wash. + final Color foreground; final TextStyle textStyle; /// The button's frame, and the accent at the status containers' 6% — @@ -447,7 +459,7 @@ class _SettledRow extends StatelessWidget { // The outcome lands unprompted too — announce the flip. Semantics( liveRegion: true, - child: Text(label, style: textStyle.copyWith(color: accent)), + child: Text(label, style: textStyle.copyWith(color: foreground)), ), ], ],