diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4567d7d..6896a6a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,20 @@
# Changelog
+## 0.3.0 (unreleased)
+
+- **Component styles** — Material's component-theme tier, on flow_ui's
+ tokens: every major widget now takes an optional style object of
+ color and text overrides (`FlowComposerStyle`, `FlowMessageStyle`,
+ `FlowMarkdownStyle`, `FlowCodeBlockStyle`, `FlowErrorStateStyle`,
+ `FlowMessageActionsStyle`, `FlowPillStyle`, `FlowSuggestionStyle`,
+ joining `FlowMenuStyle`), and `FlowTheme` carries an app-wide default
+ for each (`FlowTheme.markdownStyle`, …). Resolution is field by field —
+ the widget's style wins over the theme's, tokens beneath both; text
+ fields merge over their role's base. `FlowMarkdownStyle` opens up the
+ markdown surface per element: heading cuts, the link color, the
+ inline-code chip, quote, table and rule inks. All additive — nothing
+ breaks.
+
## 0.2.0
- **Typography** — title and label roles now carry an emphasised cut
diff --git a/docs/src/content/docs/components/code-block.mdx b/docs/src/content/docs/components/code-block.mdx
index d6eb5d6..f436289 100644
--- a/docs/src/content/docs/components/code-block.mdx
+++ b/docs/src/content/docs/components/code-block.mdx
@@ -148,6 +148,17 @@ FlowTheme(
)
```
+## Restyling
+
+`FlowCodeBlockStyle` overrides the block's chrome without touching the
+global tokens: `backgroundColor` (transparent lets the code sit on the
+host's own surface), `borderColor` (transparent renders it borderless),
+`hoverBorderColor`, `headerStyle`, and `codeStyle` (both merged over
+their defaults). Token inks inside the code stay with the theme's
+`FlowSyntaxColors`. Install one on `FlowTheme.codeBlockStyle` for every
+block — markdown fences included; the widget's `style` wins field by
+field.
+
## Key API
- `FlowCodeBlock` — `code`, `language` (id or alias, case-insensitive),
diff --git a/docs/src/content/docs/components/composer.mdx b/docs/src/content/docs/components/composer.mdx
index 5ccfd60..22d10c4 100644
--- a/docs/src/content/docs/components/composer.mdx
+++ b/docs/src/content/docs/components/composer.mdx
@@ -83,6 +83,16 @@ The composer renders a pending-attachments row above the field — pass
`attachments:` and `onRemoveAttachment:`. The [Attachments](/components/attachments/)
page shows the full flow, from the "+" menu to the sent message.
+## Restyling
+
+`FlowComposerStyle` overrides the composer's colors without touching the
+global tokens: `backgroundColor` (the raised card's fill), `outlineColor`
+(flattens the gradient hairline to one solid color in every state),
+`sendBackgroundColor` and `sendForegroundColor` (the send/stop disc),
+`textStyle` (merged over the field's default), and `hintColor`. Install
+one on `FlowTheme.composerStyle` for every composer; the widget's `style`
+wins field by field.
+
## Key API
- `onSend` — required; receives the trimmed draft, never empty text.
diff --git a/docs/src/content/docs/components/error-state.mdx b/docs/src/content/docs/components/error-state.mdx
index f921890..29ca173 100644
--- a/docs/src/content/docs/components/error-state.mdx
+++ b/docs/src/content/docs/components/error-state.mdx
@@ -106,6 +106,14 @@ FlowChatView(
)
```
+## Restyling
+
+`FlowErrorStateStyle` overrides the card's look without touching the
+global tokens: `backgroundColor`, `borderColor`, `glyphColor`,
+`titleStyle`, and `messageStyle` (both merged over their defaults).
+Install one on `FlowTheme.errorStateStyle` for every error card — failed
+turns in a thread included; the widget's `style` wins field by field.
+
## Key API
- `FlowErrorState` — `title`, `message`, `onRetry` (null hides the
diff --git a/docs/src/content/docs/components/markdown.mdx b/docs/src/content/docs/components/markdown.mdx
index 046857e..568937b 100644
--- a/docs/src/content/docs/components/markdown.mdx
+++ b/docs/src/content/docs/components/markdown.mdx
@@ -85,6 +85,18 @@ contract code parts use — one `onCodeCopy` handler and one
streaming its copy affordance stays hidden, exactly like a streaming
`FlowCodePart`.
+## Restyling
+
+`FlowMarkdownStyle` overrides the document's per-element styling without
+touching the global tokens: `h1Style` through `h6Style` (merged over each
+level's role), `linkColor`, `inlineCodeStyle` and `codeChipColor` (the
+face and the painted chip), `quoteColor` and `quoteBarColor`,
+`tableHeaderStyle`, `tableCellStyle`, `tableBorderColor` and
+`tableDividerColor`, and `ruleColor`. Install one on
+`FlowTheme.markdownStyle` for every markdown surface — assistant turns in
+a thread included; the widget's `markdownStyle` wins field by field.
+Fences restyle through `FlowCodeBlockStyle`.
+
## Key API
- `text` — the markdown source received so far.
diff --git a/docs/src/content/docs/components/menu.mdx b/docs/src/content/docs/components/menu.mdx
index 3be0dae..ded040a 100644
--- a/docs/src/content/docs/components/menu.mdx
+++ b/docs/src/content/docs/components/menu.mdx
@@ -82,12 +82,15 @@ its `localizationsDelegates`.
## Restyling
-`FlowMenuStyle` overrides the menu's colors and metrics per instance
-without touching the global tokens: `backgroundColor`, `borderColor`,
-`separatorColor`, `hoverColor`, `labelStyle`, `descriptionStyle`,
-`iconColor`, `checkColor`, `accentColor`, `menuRadius`, `sheetRadius`,
-`minWidth`, and `barrierColor`. Paddings are baked spec metrics, not style
-fields.
+`FlowMenuStyle` overrides the menu's colors and metrics without touching
+the global tokens: `backgroundColor`, `borderColor`, `separatorColor`,
+`hoverColor`, `labelStyle`, `descriptionStyle`, `iconColor`, `checkColor`,
+`accentColor`, `menuRadius`, `sheetRadius`, `minWidth`, and
+`barrierColor`. Paddings are baked spec metrics, not style fields.
+
+Install one on `FlowTheme.menuStyle` to restyle every menu and model
+selector app-wide; a widget's own `menuStyle` wins field by field, and
+the tokens sit beneath both.
## Key API
diff --git a/docs/src/content/docs/components/message-actions.mdx b/docs/src/content/docs/components/message-actions.mdx
index bf8957c..9c813db 100644
--- a/docs/src/content/docs/components/message-actions.mdx
+++ b/docs/src/content/docs/components/message-actions.mdx
@@ -59,6 +59,14 @@ FlowMessage(
)
```
+## Restyling
+
+`FlowMessageActionsStyle` overrides the strip's inks without touching the
+global tokens: `iconColor`, `hoverIconColor`, `selectedColor`, and
+`hoverColor` (the wash behind a hovered action). Install one on
+`FlowTheme.messageActionsStyle` for every actions row; the widget's
+`style` wins field by field.
+
## Key API
- `FlowMessageAction.copy / .regenerate / .edit / .thumbUp / .thumbDown` —
diff --git a/docs/src/content/docs/components/message-thread.mdx b/docs/src/content/docs/components/message-thread.mdx
index 0ae9872..c1b99ac 100644
--- a/docs/src/content/docs/components/message-thread.mdx
+++ b/docs/src/content/docs/components/message-thread.mdx
@@ -102,6 +102,15 @@ extension seam — it carries a `type` your builder switches on and an
arbitrary `data` payload, so the host injects its own content (a tool
card, a chart) without the library knowing what it is.
+## Restyling
+
+`FlowMessageStyle` overrides the user bubble without touching the global
+tokens: `bubbleColor` and `bubbleTextColor` (a failed turn keeps the
+error treatment regardless). Install one on `FlowTheme.messageStyle` for
+every message; a `FlowMessage`'s own `style` wins field by field.
+Assistant prose is restyled through `FlowMarkdownStyle`, code through
+`FlowCodeBlockStyle` — see those components' pages.
+
## Key classes
- `FlowThread` — the scrolling conversation; give it a `ScrollController`
diff --git a/docs/src/content/docs/components/pill.mdx b/docs/src/content/docs/components/pill.mdx
index 3ce2414..22f7000 100644
--- a/docs/src/content/docs/components/pill.mdx
+++ b/docs/src/content/docs/components/pill.mdx
@@ -78,6 +78,14 @@ keep concurrent pills few:
+## Restyling
+
+`FlowPillStyle` overrides the pill's colors without touching the global
+tokens: `backgroundColor`, `hoverColor`, `borderColor`, `iconColor`,
+`labelStyle` (merged over the default label), and `removeColor`. Install
+one on `FlowTheme.pillStyle` for every pill; the widget's `style` wins
+field by field.
+
## Key API
- `icon` — always drawn; the pill's whole identity in the icon-only
diff --git a/docs/src/content/docs/components/suggestions.mdx b/docs/src/content/docs/components/suggestions.mdx
index 213a324..55498b2 100644
--- a/docs/src/content/docs/components/suggestions.mdx
+++ b/docs/src/content/docs/components/suggestions.mdx
@@ -114,6 +114,14 @@ Column(
)
```
+## Restyling
+
+`FlowSuggestionStyle` overrides a suggestion's colors without touching
+the global tokens: `backgroundColor`, `borderColor` (the outlined form's
+hairline), `hoverColor`, `foregroundColor`, and `labelStyle` (merged over
+the default). Install one on `FlowTheme.suggestionStyle` for every
+suggestion; the widget's `style` wins field by field.
+
## Key API
- `FlowSuggestion` — `label`, optional `icon`, `outlined`, `onTap`,
diff --git a/docs/src/content/docs/theming.mdx b/docs/src/content/docs/theming.mdx
index cca02bc..65b629d 100644
--- a/docs/src/content/docs/theming.mdx
+++ b/docs/src/content/docs/theming.mdx
@@ -117,6 +117,31 @@ FlowTheme(
carries — size, weight, line height, tracking — and takes a `package:` for
a font that ships inside one.
+## Component styles
+
+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`,
+`FlowSuggestionStyle`), and the theme can carry an app-wide default for
+each:
+
+```dart title="Restyle every instance once"
+FlowTheme(
+ colors: FlowColors.light,
+ markdownStyle: const FlowMarkdownStyle(linkColor: brandBlue),
+ codeBlockStyle: const FlowCodeBlockStyle(borderColor: Colors.transparent),
+)
+```
+
+Resolution is field by field: a widget's own style object wins over the
+theme's, and any field neither sets falls through to the tokens. Text
+style fields merge over their role's base, so setting one property never
+restates the rest. Every field is documented on its style class, with
+the token it defaults to. The `Restyling` section on each component's
+page lists its fields.
+
## Spacing and radii are not tokens
Following Material's structure, each component bakes its own metrics from
diff --git a/lib/flow_ui.dart b/lib/flow_ui.dart
index 541fe9e..cdaa48c 100644
--- a/lib/flow_ui.dart
+++ b/lib/flow_ui.dart
@@ -17,18 +17,26 @@ export 'src/widgets/flow_attachment_group.dart';
export 'src/widgets/flow_attachment_preview.dart';
export 'src/widgets/flow_chat_view.dart';
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_error_state.dart';
+export 'src/styles/flow_error_state_style.dart';
export 'src/widgets/flow_greeting.dart';
export 'src/widgets/flow_markdown.dart';
+export 'src/styles/flow_markdown_style.dart';
export 'src/widgets/flow_menu.dart';
-export 'src/widgets/flow_menu_style.dart';
+export 'src/styles/flow_menu_style.dart';
export 'src/widgets/flow_message.dart';
export 'src/widgets/flow_message_actions.dart';
+export 'src/styles/flow_message_actions_style.dart';
+export 'src/styles/flow_message_style.dart';
export 'src/widgets/flow_model_selector.dart';
export 'src/widgets/flow_pill.dart';
+export 'src/styles/flow_pill_style.dart';
export 'src/widgets/flow_shimmer_text.dart';
export 'src/widgets/flow_streaming_text.dart';
export 'src/widgets/flow_suggestion.dart';
+export 'src/styles/flow_suggestion_style.dart';
export 'src/widgets/flow_thinking_indicator.dart';
export 'src/widgets/flow_thread.dart';
diff --git a/lib/src/styles/flow_code_block_style.dart b/lib/src/styles/flow_code_block_style.dart
new file mode 100644
index 0000000..a2669de
--- /dev/null
+++ b/lib/src/styles/flow_code_block_style.dart
@@ -0,0 +1,96 @@
+import 'package:material_ui/material_ui.dart';
+
+/// Host overrides for [FlowCodeBlock]'s look, on top of the theme tokens.
+///
+/// Every field is optional; null falls back to the token-derived default
+/// noted on the field. Token inks inside the code come from the theme's
+/// `FlowSyntaxColors`, not from here. Install one on
+/// [FlowTheme.codeBlockStyle] to restyle every block — fences in markdown
+/// included; a widget's own `style` wins field by field:
+///
+/// ```dart
+/// FlowCodeBlock(
+/// code: source,
+/// language: 'dart',
+/// style: const FlowCodeBlockStyle(
+/// backgroundColor: Colors.transparent,
+/// borderColor: Colors.transparent,
+/// ),
+/// )
+/// ```
+@immutable
+class FlowCodeBlockStyle {
+ const FlowCodeBlockStyle({
+ this.backgroundColor,
+ this.borderColor,
+ this.hoverBorderColor,
+ this.headerStyle,
+ this.codeStyle,
+ });
+
+ /// The card's fill. Defaults to `surfaceContainerLowest`. Transparent
+ /// lets the code sit directly on the host's own surface.
+ final Color? backgroundColor;
+
+ /// The card's hairline. Defaults to `outlineVariant`; transparent
+ /// renders the block borderless.
+ final Color? borderColor;
+
+ /// The hairline while hovered. Defaults to `surfaceContainerHighest`;
+ /// when only [borderColor] is set, hover keeps that color instead of
+ /// firming.
+ final Color? hoverBorderColor;
+
+ /// Merged over the header label's default `bodySmall` +
+ /// `onSurfaceMuted` style.
+ final TextStyle? headerStyle;
+
+ /// Merged over the code's default `code` + `onSurface` style. Token
+ /// colors from `FlowSyntaxColors` still apply per span.
+ final TextStyle? codeStyle;
+
+ /// A copy where [other]'s fields win over this style's.
+ FlowCodeBlockStyle merge(FlowCodeBlockStyle? other) {
+ if (other == null) return this;
+ return FlowCodeBlockStyle(
+ backgroundColor: other.backgroundColor ?? backgroundColor,
+ borderColor: other.borderColor ?? borderColor,
+ hoverBorderColor: other.hoverBorderColor ?? hoverBorderColor,
+ headerStyle: other.headerStyle ?? headerStyle,
+ codeStyle: other.codeStyle ?? codeStyle,
+ );
+ }
+
+ /// Linear interpolation, for theme transitions. A null [other] returns
+ /// this style unchanged.
+ FlowCodeBlockStyle lerp(FlowCodeBlockStyle? other, double t) {
+ if (other == null) return this;
+ return FlowCodeBlockStyle(
+ backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t),
+ borderColor: Color.lerp(borderColor, other.borderColor, t),
+ hoverBorderColor: Color.lerp(hoverBorderColor, other.hoverBorderColor, t),
+ headerStyle: TextStyle.lerp(headerStyle, other.headerStyle, t),
+ codeStyle: TextStyle.lerp(codeStyle, other.codeStyle, t),
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ return other is FlowCodeBlockStyle &&
+ other.backgroundColor == backgroundColor &&
+ other.borderColor == borderColor &&
+ other.hoverBorderColor == hoverBorderColor &&
+ other.headerStyle == headerStyle &&
+ other.codeStyle == codeStyle;
+ }
+
+ @override
+ int get hashCode => Object.hash(
+ backgroundColor,
+ borderColor,
+ hoverBorderColor,
+ headerStyle,
+ codeStyle,
+ );
+}
diff --git a/lib/src/styles/flow_composer_style.dart b/lib/src/styles/flow_composer_style.dart
new file mode 100644
index 0000000..d04b8ce
--- /dev/null
+++ b/lib/src/styles/flow_composer_style.dart
@@ -0,0 +1,106 @@
+import 'package:material_ui/material_ui.dart';
+
+/// Host overrides for [FlowComposer]'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.composerStyle] to restyle
+/// every composer; a widget's own `style` wins field by field:
+///
+/// ```dart
+/// FlowComposer(
+/// onSend: send,
+/// style: const FlowComposerStyle(
+/// backgroundColor: Color(0xFF102030),
+/// sendBackgroundColor: Color(0xFFB65C33),
+/// ),
+/// )
+/// ```
+@immutable
+class FlowComposerStyle {
+ const FlowComposerStyle({
+ this.backgroundColor,
+ this.outlineColor,
+ this.sendBackgroundColor,
+ this.sendForegroundColor,
+ this.textStyle,
+ this.hintColor,
+ });
+
+ /// The card's fill. Defaults to `surfaceBright`, the raised card's
+ /// ground in both themes.
+ final Color? backgroundColor;
+
+ /// The card's 1px hairline. The default is a gradient from ink at 14%
+ /// to ink at 8% (20% to 12% while hovered or focused); setting this
+ /// flattens it to one solid color in every state.
+ final Color? outlineColor;
+
+ /// The send/stop disc. Defaults to `primary`.
+ final Color? sendBackgroundColor;
+
+ /// The glyph on the send/stop disc. Defaults to `onPrimary`.
+ final Color? sendForegroundColor;
+
+ /// Merged over the field's default `bodyLarge` + `onSurface` style.
+ final TextStyle? textStyle;
+
+ /// The placeholder hint. Defaults to `onSurfaceMuted`.
+ final Color? hintColor;
+
+ /// A copy where [other]'s fields win over this style's.
+ FlowComposerStyle merge(FlowComposerStyle? other) {
+ if (other == null) return this;
+ return FlowComposerStyle(
+ backgroundColor: other.backgroundColor ?? backgroundColor,
+ outlineColor: other.outlineColor ?? outlineColor,
+ sendBackgroundColor: other.sendBackgroundColor ?? sendBackgroundColor,
+ sendForegroundColor: other.sendForegroundColor ?? sendForegroundColor,
+ textStyle: other.textStyle ?? textStyle,
+ hintColor: other.hintColor ?? hintColor,
+ );
+ }
+
+ /// Linear interpolation, for theme transitions. A null [other] returns
+ /// this style unchanged.
+ FlowComposerStyle lerp(FlowComposerStyle? other, double t) {
+ if (other == null) return this;
+ return FlowComposerStyle(
+ backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t),
+ outlineColor: Color.lerp(outlineColor, other.outlineColor, t),
+ sendBackgroundColor: Color.lerp(
+ sendBackgroundColor,
+ other.sendBackgroundColor,
+ t,
+ ),
+ sendForegroundColor: Color.lerp(
+ sendForegroundColor,
+ other.sendForegroundColor,
+ t,
+ ),
+ textStyle: TextStyle.lerp(textStyle, other.textStyle, t),
+ hintColor: Color.lerp(hintColor, other.hintColor, t),
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ return other is FlowComposerStyle &&
+ other.backgroundColor == backgroundColor &&
+ other.outlineColor == outlineColor &&
+ other.sendBackgroundColor == sendBackgroundColor &&
+ other.sendForegroundColor == sendForegroundColor &&
+ other.textStyle == textStyle &&
+ other.hintColor == hintColor;
+ }
+
+ @override
+ int get hashCode => Object.hash(
+ backgroundColor,
+ outlineColor,
+ sendBackgroundColor,
+ sendForegroundColor,
+ textStyle,
+ hintColor,
+ );
+}
diff --git a/lib/src/styles/flow_error_state_style.dart b/lib/src/styles/flow_error_state_style.dart
new file mode 100644
index 0000000..433e39a
--- /dev/null
+++ b/lib/src/styles/flow_error_state_style.dart
@@ -0,0 +1,86 @@
+import 'package:material_ui/material_ui.dart';
+
+/// Host overrides for [FlowErrorState]'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.errorStateStyle] to
+/// restyle every error card — failed turns in a thread included; a
+/// widget's own `style` wins field by field:
+///
+/// ```dart
+/// FlowErrorState(
+/// message: 'The model is overloaded.',
+/// style: const FlowErrorStateStyle(glyphColor: Color(0xFFB65C33)),
+/// )
+/// ```
+@immutable
+class FlowErrorStateStyle {
+ const FlowErrorStateStyle({
+ this.backgroundColor,
+ this.borderColor,
+ this.glyphColor,
+ this.titleStyle,
+ this.messageStyle,
+ });
+
+ /// The card's fill. Defaults to ink at 2%.
+ final Color? backgroundColor;
+
+ /// The card's hairline. Defaults to `error` at 40%.
+ final Color? borderColor;
+
+ /// The error glyph. Defaults to `error`.
+ final Color? glyphColor;
+
+ /// Merged over the title's default style.
+ final TextStyle? titleStyle;
+
+ /// Merged over the message's default `bodyMedium` + `onSurfaceVariant`
+ /// style.
+ final TextStyle? messageStyle;
+
+ /// A copy where [other]'s fields win over this style's.
+ FlowErrorStateStyle merge(FlowErrorStateStyle? other) {
+ if (other == null) return this;
+ return FlowErrorStateStyle(
+ backgroundColor: other.backgroundColor ?? backgroundColor,
+ borderColor: other.borderColor ?? borderColor,
+ glyphColor: other.glyphColor ?? glyphColor,
+ titleStyle: other.titleStyle ?? titleStyle,
+ messageStyle: other.messageStyle ?? messageStyle,
+ );
+ }
+
+ /// Linear interpolation, for theme transitions. A null [other] returns
+ /// this style unchanged.
+ FlowErrorStateStyle lerp(FlowErrorStateStyle? other, double t) {
+ if (other == null) return this;
+ return FlowErrorStateStyle(
+ backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t),
+ borderColor: Color.lerp(borderColor, other.borderColor, t),
+ glyphColor: Color.lerp(glyphColor, other.glyphColor, t),
+ titleStyle: TextStyle.lerp(titleStyle, other.titleStyle, t),
+ messageStyle: TextStyle.lerp(messageStyle, other.messageStyle, t),
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ return other is FlowErrorStateStyle &&
+ other.backgroundColor == backgroundColor &&
+ other.borderColor == borderColor &&
+ other.glyphColor == glyphColor &&
+ other.titleStyle == titleStyle &&
+ other.messageStyle == messageStyle;
+ }
+
+ @override
+ int get hashCode => Object.hash(
+ backgroundColor,
+ borderColor,
+ glyphColor,
+ titleStyle,
+ messageStyle,
+ );
+}
diff --git a/lib/src/styles/flow_markdown_style.dart b/lib/src/styles/flow_markdown_style.dart
new file mode 100644
index 0000000..875792c
--- /dev/null
+++ b/lib/src/styles/flow_markdown_style.dart
@@ -0,0 +1,193 @@
+import 'package:material_ui/material_ui.dart';
+
+/// Host overrides for [FlowMarkdown]'s look, on top of the theme tokens.
+///
+/// Every field is optional; null falls back to the token-derived default
+/// noted on the field. Text styles merge over their role's base, so a
+/// field can change one property without restating the rest. Install one
+/// on [FlowTheme.markdownStyle] to restyle every markdown surface —
+/// assistant turns in a thread included; a widget's own `style` object
+/// wins field by field:
+///
+/// ```dart
+/// FlowMarkdown(
+/// text: reply,
+/// onLinkTap: open,
+/// markdownStyle: const FlowMarkdownStyle(
+/// linkColor: Color(0xFF3366CC),
+/// h1Style: TextStyle(fontSize: 26),
+/// ),
+/// )
+/// ```
+@immutable
+class FlowMarkdownStyle {
+ const FlowMarkdownStyle({
+ this.h1Style,
+ this.h2Style,
+ this.h3Style,
+ this.h4Style,
+ this.h5Style,
+ this.h6Style,
+ this.linkColor,
+ this.inlineCodeStyle,
+ this.codeChipColor,
+ this.quoteColor,
+ this.quoteBarColor,
+ this.tableHeaderStyle,
+ this.tableCellStyle,
+ this.tableBorderColor,
+ this.tableDividerColor,
+ this.ruleColor,
+ });
+
+ /// Merged over `# `'s default `titleLarge`.
+ final TextStyle? h1Style;
+
+ /// Merged over `## `'s default `titleMedium`.
+ final TextStyle? h2Style;
+
+ /// Merged over `### `'s default `titleSmall`.
+ final TextStyle? h3Style;
+
+ /// Merged over `#### `'s default `bodyLargeDark`.
+ final TextStyle? h4Style;
+
+ /// Merged over `##### `'s default `bodyMediumDark`.
+ final TextStyle? h5Style;
+
+ /// Merged over `###### `'s default `bodyMediumDark` +
+ /// `onSurfaceVariant`.
+ final TextStyle? h6Style;
+
+ /// Link labels and their underline. Defaults to `tertiary`.
+ final Color? linkColor;
+
+ /// Merged over inline code's default `codeInline` face.
+ final TextStyle? inlineCodeStyle;
+
+ /// The rounded chip painted under inline code. Defaults to ink at 4%.
+ final Color? codeChipColor;
+
+ /// A quote's prose ink. Defaults to `onSurfaceVariant`.
+ final Color? quoteColor;
+
+ /// A quote's leading bar. Defaults to `outline`.
+ final Color? quoteBarColor;
+
+ /// Merged over a table header cell's default `bodyMediumDark`.
+ final TextStyle? tableHeaderStyle;
+
+ /// Merged over a table body cell's default `bodyMedium`.
+ final TextStyle? tableCellStyle;
+
+ /// The hairline under a table's header row. Defaults to `outline`.
+ final Color? tableBorderColor;
+
+ /// The hairline between a table's body rows. Defaults to
+ /// `outlineVariant`.
+ final Color? tableDividerColor;
+
+ /// A horizontal rule. Defaults to `outlineVariant`.
+ final Color? ruleColor;
+
+ /// A copy where [other]'s fields win over this style's.
+ FlowMarkdownStyle merge(FlowMarkdownStyle? other) {
+ if (other == null) return this;
+ return FlowMarkdownStyle(
+ h1Style: other.h1Style ?? h1Style,
+ h2Style: other.h2Style ?? h2Style,
+ h3Style: other.h3Style ?? h3Style,
+ h4Style: other.h4Style ?? h4Style,
+ h5Style: other.h5Style ?? h5Style,
+ h6Style: other.h6Style ?? h6Style,
+ linkColor: other.linkColor ?? linkColor,
+ inlineCodeStyle: other.inlineCodeStyle ?? inlineCodeStyle,
+ codeChipColor: other.codeChipColor ?? codeChipColor,
+ quoteColor: other.quoteColor ?? quoteColor,
+ quoteBarColor: other.quoteBarColor ?? quoteBarColor,
+ tableHeaderStyle: other.tableHeaderStyle ?? tableHeaderStyle,
+ tableCellStyle: other.tableCellStyle ?? tableCellStyle,
+ tableBorderColor: other.tableBorderColor ?? tableBorderColor,
+ tableDividerColor: other.tableDividerColor ?? tableDividerColor,
+ ruleColor: other.ruleColor ?? ruleColor,
+ );
+ }
+
+ /// Linear interpolation, for theme transitions. A null [other] returns
+ /// this style unchanged.
+ FlowMarkdownStyle lerp(FlowMarkdownStyle? other, double t) {
+ if (other == null) return this;
+ return FlowMarkdownStyle(
+ h1Style: TextStyle.lerp(h1Style, other.h1Style, t),
+ h2Style: TextStyle.lerp(h2Style, other.h2Style, t),
+ h3Style: TextStyle.lerp(h3Style, other.h3Style, t),
+ h4Style: TextStyle.lerp(h4Style, other.h4Style, t),
+ h5Style: TextStyle.lerp(h5Style, other.h5Style, t),
+ h6Style: TextStyle.lerp(h6Style, other.h6Style, t),
+ linkColor: Color.lerp(linkColor, other.linkColor, t),
+ inlineCodeStyle: TextStyle.lerp(
+ inlineCodeStyle,
+ other.inlineCodeStyle,
+ t,
+ ),
+ codeChipColor: Color.lerp(codeChipColor, other.codeChipColor, t),
+ quoteColor: Color.lerp(quoteColor, other.quoteColor, t),
+ quoteBarColor: Color.lerp(quoteBarColor, other.quoteBarColor, t),
+ tableHeaderStyle: TextStyle.lerp(
+ tableHeaderStyle,
+ other.tableHeaderStyle,
+ t,
+ ),
+ tableCellStyle: TextStyle.lerp(tableCellStyle, other.tableCellStyle, t),
+ tableBorderColor: Color.lerp(tableBorderColor, other.tableBorderColor, t),
+ tableDividerColor: Color.lerp(
+ tableDividerColor,
+ other.tableDividerColor,
+ t,
+ ),
+ ruleColor: Color.lerp(ruleColor, other.ruleColor, t),
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ return other is FlowMarkdownStyle &&
+ other.h1Style == h1Style &&
+ other.h2Style == h2Style &&
+ other.h3Style == h3Style &&
+ other.h4Style == h4Style &&
+ other.h5Style == h5Style &&
+ other.h6Style == h6Style &&
+ other.linkColor == linkColor &&
+ other.inlineCodeStyle == inlineCodeStyle &&
+ other.codeChipColor == codeChipColor &&
+ other.quoteColor == quoteColor &&
+ other.quoteBarColor == quoteBarColor &&
+ other.tableHeaderStyle == tableHeaderStyle &&
+ other.tableCellStyle == tableCellStyle &&
+ other.tableBorderColor == tableBorderColor &&
+ other.tableDividerColor == tableDividerColor &&
+ other.ruleColor == ruleColor;
+ }
+
+ @override
+ int get hashCode => Object.hash(
+ h1Style,
+ h2Style,
+ h3Style,
+ h4Style,
+ h5Style,
+ h6Style,
+ linkColor,
+ inlineCodeStyle,
+ codeChipColor,
+ quoteColor,
+ quoteBarColor,
+ tableHeaderStyle,
+ tableCellStyle,
+ tableBorderColor,
+ tableDividerColor,
+ ruleColor,
+ );
+}
diff --git a/lib/src/styles/flow_menu_style.dart b/lib/src/styles/flow_menu_style.dart
new file mode 100644
index 0000000..1db6d51
--- /dev/null
+++ b/lib/src/styles/flow_menu_style.dart
@@ -0,0 +1,180 @@
+import 'dart:ui' show lerpDouble;
+
+import 'package:material_ui/material_ui.dart';
+
+/// How a flow_ui menu presents when its trigger is tapped.
+enum FlowMenuPresentation {
+ /// A bottom sheet on iOS and Android, an anchored menu everywhere else.
+ ///
+ /// Resolved against `Theme.of(context).platform`, so a host (or a test)
+ /// can steer it by overriding the ambient theme's platform.
+ auto,
+
+ /// Always an anchored menu, hanging off the trigger.
+ menu,
+
+ /// Always a modal bottom sheet.
+ sheet,
+}
+
+/// Host overrides for a flow_ui menu's look, on top of the theme tokens.
+///
+/// Every field is optional; null falls back to the token-derived default
+/// noted on the field. One instance covers both presentations — the anchored
+/// menu and the bottom sheet draw from the same palette. Install one on
+/// [FlowTheme.menuStyle] to restyle every menu; a widget's own `menuStyle`
+/// wins field by field:
+///
+/// ```dart
+/// FlowModelSelector(
+/// models: models,
+/// menuStyle: const FlowMenuStyle(
+/// backgroundColor: Color(0xFF102030),
+/// labelStyle: TextStyle(fontSize: 15),
+/// ),
+/// )
+/// ```
+@immutable
+class FlowMenuStyle {
+ const FlowMenuStyle({
+ this.backgroundColor,
+ this.borderColor,
+ this.separatorColor,
+ this.hoverColor,
+ this.labelStyle,
+ this.descriptionStyle,
+ this.iconColor,
+ this.checkColor,
+ this.accentColor,
+ this.menuRadius,
+ this.sheetRadius,
+ this.minWidth,
+ this.barrierColor,
+ });
+
+ /// The menu card and the sheet. Defaults to `surfaceBright`.
+ final Color? backgroundColor;
+
+ /// Hairline around the card and the sheet. The card's default is a
+ /// gradient from ink at 20% to ink at 12%; setting this flattens it to
+ /// one solid color. The sheet's default is ink at 20%.
+ final Color? borderColor;
+
+ /// Rule between sections. Defaults to ink at 10%.
+ final Color? separatorColor;
+
+ /// Row fill on hover and focus. Defaults to `surfaceContainer`.
+ final Color? hoverColor;
+
+ /// Merged over the default row label style (`labelMediumEmphasised` in
+ /// the menu, `labelLargeEmphasised` in the sheet).
+ final TextStyle? labelStyle;
+
+ /// Merged over the default description style (`labelMedium` in
+ /// `onSurfaceMuted`).
+ final TextStyle? descriptionStyle;
+
+ /// Leading row icons. Defaults to `onSurfaceVariant`.
+ final Color? iconColor;
+
+ /// The selected check. Defaults to `primary`.
+ final Color? checkColor;
+
+ /// Accented trailing values, e.g. the chosen effort on its row.
+ /// Defaults to `primary`.
+ final Color? accentColor;
+
+ /// Corner radius of the anchored menu card. Defaults to the design's 12.
+ /// The bottom sheet's top corners come from [sheetRadius].
+ final BorderRadius? menuRadius;
+
+ /// The bottom sheet's top corners. Defaults to the design's 24.
+ final Radius? sheetRadius;
+
+ /// Minimum width of the anchored menu's rows. Defaults to 220.
+ final double? minWidth;
+
+ /// Scrim behind the bottom sheet. Defaults to the framework's.
+ final Color? barrierColor;
+
+ /// A copy where [other]'s fields win over this style's.
+ FlowMenuStyle merge(FlowMenuStyle? other) {
+ if (other == null) return this;
+ return FlowMenuStyle(
+ backgroundColor: other.backgroundColor ?? backgroundColor,
+ borderColor: other.borderColor ?? borderColor,
+ separatorColor: other.separatorColor ?? separatorColor,
+ hoverColor: other.hoverColor ?? hoverColor,
+ labelStyle: other.labelStyle ?? labelStyle,
+ descriptionStyle: other.descriptionStyle ?? descriptionStyle,
+ iconColor: other.iconColor ?? iconColor,
+ checkColor: other.checkColor ?? checkColor,
+ accentColor: other.accentColor ?? accentColor,
+ menuRadius: other.menuRadius ?? menuRadius,
+ sheetRadius: other.sheetRadius ?? sheetRadius,
+ minWidth: other.minWidth ?? minWidth,
+ barrierColor: other.barrierColor ?? barrierColor,
+ );
+ }
+
+ /// Linear interpolation, for theme transitions. A null [other] returns
+ /// this style unchanged.
+ FlowMenuStyle lerp(FlowMenuStyle? other, double t) {
+ if (other == null) return this;
+ return FlowMenuStyle(
+ backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t),
+ borderColor: Color.lerp(borderColor, other.borderColor, t),
+ separatorColor: Color.lerp(separatorColor, other.separatorColor, t),
+ hoverColor: Color.lerp(hoverColor, other.hoverColor, t),
+ labelStyle: TextStyle.lerp(labelStyle, other.labelStyle, t),
+ descriptionStyle: TextStyle.lerp(
+ descriptionStyle,
+ other.descriptionStyle,
+ t,
+ ),
+ iconColor: Color.lerp(iconColor, other.iconColor, t),
+ checkColor: Color.lerp(checkColor, other.checkColor, t),
+ accentColor: Color.lerp(accentColor, other.accentColor, t),
+ menuRadius: BorderRadius.lerp(menuRadius, other.menuRadius, t),
+ sheetRadius: Radius.lerp(sheetRadius, other.sheetRadius, t),
+ minWidth: lerpDouble(minWidth, other.minWidth, t),
+ barrierColor: Color.lerp(barrierColor, other.barrierColor, t),
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ return other is FlowMenuStyle &&
+ other.backgroundColor == backgroundColor &&
+ other.borderColor == borderColor &&
+ other.separatorColor == separatorColor &&
+ other.hoverColor == hoverColor &&
+ other.labelStyle == labelStyle &&
+ other.descriptionStyle == descriptionStyle &&
+ other.iconColor == iconColor &&
+ other.checkColor == checkColor &&
+ other.accentColor == accentColor &&
+ other.menuRadius == menuRadius &&
+ other.sheetRadius == sheetRadius &&
+ other.minWidth == minWidth &&
+ other.barrierColor == barrierColor;
+ }
+
+ @override
+ int get hashCode => Object.hash(
+ backgroundColor,
+ borderColor,
+ separatorColor,
+ hoverColor,
+ labelStyle,
+ descriptionStyle,
+ iconColor,
+ checkColor,
+ accentColor,
+ menuRadius,
+ sheetRadius,
+ minWidth,
+ barrierColor,
+ );
+}
diff --git a/lib/src/styles/flow_message_actions_style.dart b/lib/src/styles/flow_message_actions_style.dart
new file mode 100644
index 0000000..43068db
--- /dev/null
+++ b/lib/src/styles/flow_message_actions_style.dart
@@ -0,0 +1,73 @@
+import 'package:material_ui/material_ui.dart';
+
+/// Host overrides for [FlowMessageActions]' 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.messageActionsStyle] to
+/// restyle every actions row; a widget's own `style` wins field by field:
+///
+/// ```dart
+/// FlowMessageActions(
+/// actions: actions,
+/// style: const FlowMessageActionsStyle(selectedColor: Color(0xFF33A060)),
+/// )
+/// ```
+@immutable
+class FlowMessageActionsStyle {
+ const FlowMessageActionsStyle({
+ this.iconColor,
+ this.hoverIconColor,
+ this.selectedColor,
+ this.hoverColor,
+ });
+
+ /// Action icons at rest. Defaults to `onSurfaceMuted`.
+ final Color? iconColor;
+
+ /// Action icons while hovered. Defaults to `onSurface`.
+ final Color? hoverIconColor;
+
+ /// A selected action's icon (a chosen thumb). Defaults to `primary`.
+ final Color? selectedColor;
+
+ /// The wash behind a hovered action. Defaults to `surfaceContainerHigh`.
+ final Color? hoverColor;
+
+ /// A copy where [other]'s fields win over this style's.
+ FlowMessageActionsStyle merge(FlowMessageActionsStyle? other) {
+ if (other == null) return this;
+ return FlowMessageActionsStyle(
+ iconColor: other.iconColor ?? iconColor,
+ hoverIconColor: other.hoverIconColor ?? hoverIconColor,
+ selectedColor: other.selectedColor ?? selectedColor,
+ hoverColor: other.hoverColor ?? hoverColor,
+ );
+ }
+
+ /// Linear interpolation, for theme transitions. A null [other] returns
+ /// this style unchanged.
+ FlowMessageActionsStyle lerp(FlowMessageActionsStyle? other, double t) {
+ if (other == null) return this;
+ return FlowMessageActionsStyle(
+ iconColor: Color.lerp(iconColor, other.iconColor, t),
+ hoverIconColor: Color.lerp(hoverIconColor, other.hoverIconColor, t),
+ selectedColor: Color.lerp(selectedColor, other.selectedColor, t),
+ hoverColor: Color.lerp(hoverColor, other.hoverColor, t),
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ return other is FlowMessageActionsStyle &&
+ other.iconColor == iconColor &&
+ other.hoverIconColor == hoverIconColor &&
+ other.selectedColor == selectedColor &&
+ other.hoverColor == hoverColor;
+ }
+
+ @override
+ int get hashCode =>
+ Object.hash(iconColor, hoverIconColor, selectedColor, hoverColor);
+}
diff --git a/lib/src/styles/flow_message_style.dart b/lib/src/styles/flow_message_style.dart
new file mode 100644
index 0000000..e3ad430
--- /dev/null
+++ b/lib/src/styles/flow_message_style.dart
@@ -0,0 +1,56 @@
+import 'package:material_ui/material_ui.dart';
+
+/// Host overrides for [FlowMessage]'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.messageStyle] to restyle
+/// every message; a widget's own `style` wins field by field:
+///
+/// ```dart
+/// FlowMessage(
+/// message,
+/// style: const FlowMessageStyle(bubbleColor: Color(0x14336699)),
+/// )
+/// ```
+@immutable
+class FlowMessageStyle {
+ const FlowMessageStyle({this.bubbleColor, this.bubbleTextColor});
+
+ /// The user bubble's fill. Defaults to ink at 4%. A failed user turn
+ /// keeps the theme's `errorContainer` regardless.
+ final Color? bubbleColor;
+
+ /// The user bubble's ink. Defaults to `onSurface`. A failed user turn
+ /// keeps the theme's `onErrorContainer` regardless.
+ final Color? bubbleTextColor;
+
+ /// A copy where [other]'s fields win over this style's.
+ FlowMessageStyle merge(FlowMessageStyle? other) {
+ if (other == null) return this;
+ return FlowMessageStyle(
+ bubbleColor: other.bubbleColor ?? bubbleColor,
+ bubbleTextColor: other.bubbleTextColor ?? bubbleTextColor,
+ );
+ }
+
+ /// Linear interpolation, for theme transitions. A null [other] returns
+ /// this style unchanged.
+ FlowMessageStyle lerp(FlowMessageStyle? other, double t) {
+ if (other == null) return this;
+ return FlowMessageStyle(
+ bubbleColor: Color.lerp(bubbleColor, other.bubbleColor, t),
+ bubbleTextColor: Color.lerp(bubbleTextColor, other.bubbleTextColor, t),
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ return other is FlowMessageStyle &&
+ other.bubbleColor == bubbleColor &&
+ other.bubbleTextColor == bubbleTextColor;
+ }
+
+ @override
+ int get hashCode => Object.hash(bubbleColor, bubbleTextColor);
+}
diff --git a/lib/src/styles/flow_pill_style.dart b/lib/src/styles/flow_pill_style.dart
new file mode 100644
index 0000000..d079cef
--- /dev/null
+++ b/lib/src/styles/flow_pill_style.dart
@@ -0,0 +1,94 @@
+import 'package:material_ui/material_ui.dart';
+
+/// Host overrides for [FlowPill]'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.pillStyle] to restyle
+/// every pill; a widget's own `style` wins field by field:
+///
+/// ```dart
+/// FlowPill(
+/// icon: icon,
+/// label: 'Research',
+/// style: const FlowPillStyle(backgroundColor: Color(0x1433A060)),
+/// )
+/// ```
+@immutable
+class FlowPillStyle {
+ const FlowPillStyle({
+ this.backgroundColor,
+ this.hoverColor,
+ this.borderColor,
+ this.iconColor,
+ this.labelStyle,
+ this.removeColor,
+ });
+
+ /// The pill's fill. Defaults to `surfaceContainerLow`.
+ final Color? backgroundColor;
+
+ /// The fill while hovered. Defaults to `surfaceContainer`.
+ final Color? hoverColor;
+
+ /// The pill's hairline. Defaults to `outlineVariant`.
+ final Color? borderColor;
+
+ /// The leading icon. Defaults to `onSurfaceVariant`.
+ final Color? iconColor;
+
+ /// Merged over the default `labelMediumEmphasised` + `onSurface` label.
+ final TextStyle? labelStyle;
+
+ /// The remove X at rest. Defaults to `onSurfaceMuted`; hover lifts it
+ /// to `onSurface` regardless.
+ final Color? removeColor;
+
+ /// A copy where [other]'s fields win over this style's.
+ FlowPillStyle merge(FlowPillStyle? other) {
+ if (other == null) return this;
+ return FlowPillStyle(
+ backgroundColor: other.backgroundColor ?? backgroundColor,
+ hoverColor: other.hoverColor ?? hoverColor,
+ borderColor: other.borderColor ?? borderColor,
+ iconColor: other.iconColor ?? iconColor,
+ labelStyle: other.labelStyle ?? labelStyle,
+ removeColor: other.removeColor ?? removeColor,
+ );
+ }
+
+ /// Linear interpolation, for theme transitions. A null [other] returns
+ /// this style unchanged.
+ FlowPillStyle lerp(FlowPillStyle? other, double t) {
+ if (other == null) return this;
+ return FlowPillStyle(
+ backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t),
+ hoverColor: Color.lerp(hoverColor, other.hoverColor, t),
+ borderColor: Color.lerp(borderColor, other.borderColor, t),
+ iconColor: Color.lerp(iconColor, other.iconColor, t),
+ labelStyle: TextStyle.lerp(labelStyle, other.labelStyle, t),
+ removeColor: Color.lerp(removeColor, other.removeColor, t),
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ return other is FlowPillStyle &&
+ other.backgroundColor == backgroundColor &&
+ other.hoverColor == hoverColor &&
+ other.borderColor == borderColor &&
+ other.iconColor == iconColor &&
+ other.labelStyle == labelStyle &&
+ other.removeColor == removeColor;
+ }
+
+ @override
+ int get hashCode => Object.hash(
+ backgroundColor,
+ hoverColor,
+ borderColor,
+ iconColor,
+ labelStyle,
+ removeColor,
+ );
+}
diff --git a/lib/src/styles/flow_suggestion_style.dart b/lib/src/styles/flow_suggestion_style.dart
new file mode 100644
index 0000000..6c8f303
--- /dev/null
+++ b/lib/src/styles/flow_suggestion_style.dart
@@ -0,0 +1,88 @@
+import 'package:material_ui/material_ui.dart';
+
+/// Host overrides for [FlowSuggestion]'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.suggestionStyle] to
+/// restyle every suggestion; a widget's own `style` wins field by field:
+///
+/// ```dart
+/// FlowSuggestion(
+/// label: 'Plan my week',
+/// style: const FlowSuggestionStyle(borderColor: Color(0x33336699)),
+/// )
+/// ```
+@immutable
+class FlowSuggestionStyle {
+ const FlowSuggestionStyle({
+ this.backgroundColor,
+ this.borderColor,
+ this.hoverColor,
+ this.foregroundColor,
+ this.labelStyle,
+ });
+
+ /// The row's fill. Defaults to ink at 2% on the outlined form and
+ /// transparent on the plain one.
+ final Color? backgroundColor;
+
+ /// The outlined form's hairline. Defaults to `outlineVariant`; the
+ /// plain form draws none regardless.
+ final Color? borderColor;
+
+ /// The fill while hovered. Defaults to `surfaceContainerLow`.
+ final Color? hoverColor;
+
+ /// Icon and label ink at rest. Defaults to `onSurface` on the outlined
+ /// form and `onSurfaceVariant` on the plain one; hover lifts both to
+ /// `onSurface` regardless.
+ final Color? foregroundColor;
+
+ /// Merged over the default label style.
+ final TextStyle? labelStyle;
+
+ /// A copy where [other]'s fields win over this style's.
+ FlowSuggestionStyle merge(FlowSuggestionStyle? other) {
+ if (other == null) return this;
+ return FlowSuggestionStyle(
+ backgroundColor: other.backgroundColor ?? backgroundColor,
+ borderColor: other.borderColor ?? borderColor,
+ hoverColor: other.hoverColor ?? hoverColor,
+ foregroundColor: other.foregroundColor ?? foregroundColor,
+ labelStyle: other.labelStyle ?? labelStyle,
+ );
+ }
+
+ /// Linear interpolation, for theme transitions. A null [other] returns
+ /// this style unchanged.
+ FlowSuggestionStyle lerp(FlowSuggestionStyle? other, double t) {
+ if (other == null) return this;
+ return FlowSuggestionStyle(
+ backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t),
+ borderColor: Color.lerp(borderColor, other.borderColor, t),
+ hoverColor: Color.lerp(hoverColor, other.hoverColor, t),
+ foregroundColor: Color.lerp(foregroundColor, other.foregroundColor, t),
+ labelStyle: TextStyle.lerp(labelStyle, other.labelStyle, t),
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ return other is FlowSuggestionStyle &&
+ other.backgroundColor == backgroundColor &&
+ other.borderColor == borderColor &&
+ other.hoverColor == hoverColor &&
+ other.foregroundColor == foregroundColor &&
+ other.labelStyle == labelStyle;
+ }
+
+ @override
+ int get hashCode => Object.hash(
+ backgroundColor,
+ borderColor,
+ hoverColor,
+ foregroundColor,
+ labelStyle,
+ );
+}
diff --git a/lib/src/theme/flow_theme.dart b/lib/src/theme/flow_theme.dart
index 2363ba2..cfdaabb 100644
--- a/lib/src/theme/flow_theme.dart
+++ b/lib/src/theme/flow_theme.dart
@@ -1,5 +1,14 @@
import 'package:material_ui/material_ui.dart';
+import '../styles/flow_code_block_style.dart';
+import '../styles/flow_composer_style.dart';
+import '../styles/flow_error_state_style.dart';
+import '../styles/flow_markdown_style.dart';
+import '../styles/flow_menu_style.dart';
+import '../styles/flow_message_actions_style.dart';
+import '../styles/flow_message_style.dart';
+import '../styles/flow_pill_style.dart';
+import '../styles/flow_suggestion_style.dart';
import 'flow_colors.dart';
import 'flow_syntax_colors.dart';
import 'flow_typography.dart';
@@ -22,6 +31,11 @@ import 'flow_typography.dart';
/// matching the ambient [ThemeData.brightness] is used, so flow_ui works
/// with zero host setup.
///
+/// Beyond the tokens, the theme can carry component styles — app-wide
+/// defaults for a widget family's look ([menuStyle], [markdownStyle], …).
+/// A widget's own style object wins over the theme's, field by field, and
+/// the tokens sit beneath both.
+///
/// Spacing and corner radii are not tokens: following Material's structure,
/// each component bakes its own metrics from the design file and exposes
/// per-widget overrides (`padding:`, `borderRadius:`) where hosts retheme.
@@ -30,6 +44,15 @@ class FlowTheme extends ThemeExtension {
required this.colors,
this.typography = FlowTypography.standard,
this.syntax,
+ this.composerStyle,
+ this.messageStyle,
+ this.menuStyle,
+ this.markdownStyle,
+ this.codeBlockStyle,
+ this.errorStateStyle,
+ this.messageActionsStyle,
+ this.pillStyle,
+ this.suggestionStyle,
});
/// Light preset.
@@ -49,16 +72,62 @@ class FlowTheme extends ThemeExtension {
/// default can't see.
final FlowSyntaxColors? syntax;
+ /// App-wide default for every `FlowComposer`.
+ final FlowComposerStyle? composerStyle;
+
+ /// App-wide default for every `FlowMessage` — threads included.
+ final FlowMessageStyle? messageStyle;
+
+ /// App-wide default for every `FlowMenu` and `FlowModelSelector`.
+ final FlowMenuStyle? menuStyle;
+
+ /// App-wide default for every `FlowMarkdown` — assistant turns included.
+ final FlowMarkdownStyle? markdownStyle;
+
+ /// App-wide default for every `FlowCodeBlock` — markdown fences and
+ /// code parts included.
+ final FlowCodeBlockStyle? codeBlockStyle;
+
+ /// App-wide default for every `FlowErrorState` — failed turns included.
+ final FlowErrorStateStyle? errorStateStyle;
+
+ /// App-wide default for every `FlowMessageActions`.
+ final FlowMessageActionsStyle? messageActionsStyle;
+
+ /// App-wide default for every `FlowPill`.
+ final FlowPillStyle? pillStyle;
+
+ /// App-wide default for every `FlowSuggestion`.
+ final FlowSuggestionStyle? suggestionStyle;
+
@override
FlowTheme copyWith({
FlowColors? colors,
FlowTypography? typography,
FlowSyntaxColors? syntax,
+ FlowComposerStyle? composerStyle,
+ FlowMessageStyle? messageStyle,
+ FlowMenuStyle? menuStyle,
+ FlowMarkdownStyle? markdownStyle,
+ FlowCodeBlockStyle? codeBlockStyle,
+ FlowErrorStateStyle? errorStateStyle,
+ FlowMessageActionsStyle? messageActionsStyle,
+ FlowPillStyle? pillStyle,
+ FlowSuggestionStyle? suggestionStyle,
}) {
return FlowTheme(
colors: colors ?? this.colors,
typography: typography ?? this.typography,
syntax: syntax ?? this.syntax,
+ composerStyle: composerStyle ?? this.composerStyle,
+ messageStyle: messageStyle ?? this.messageStyle,
+ menuStyle: menuStyle ?? this.menuStyle,
+ markdownStyle: markdownStyle ?? this.markdownStyle,
+ codeBlockStyle: codeBlockStyle ?? this.codeBlockStyle,
+ errorStateStyle: errorStateStyle ?? this.errorStateStyle,
+ messageActionsStyle: messageActionsStyle ?? this.messageActionsStyle,
+ pillStyle: pillStyle ?? this.pillStyle,
+ suggestionStyle: suggestionStyle ?? this.suggestionStyle,
);
}
@@ -69,6 +138,33 @@ class FlowTheme extends ThemeExtension {
colors: colors.lerp(other.colors, t),
typography: typography.lerp(other.typography, t),
syntax: syntax == null ? other.syntax : syntax!.lerp(other.syntax, t),
+ composerStyle: composerStyle == null
+ ? other.composerStyle
+ : composerStyle!.lerp(other.composerStyle, t),
+ messageStyle: messageStyle == null
+ ? other.messageStyle
+ : messageStyle!.lerp(other.messageStyle, t),
+ menuStyle: menuStyle == null
+ ? other.menuStyle
+ : menuStyle!.lerp(other.menuStyle, t),
+ markdownStyle: markdownStyle == null
+ ? other.markdownStyle
+ : markdownStyle!.lerp(other.markdownStyle, t),
+ codeBlockStyle: codeBlockStyle == null
+ ? other.codeBlockStyle
+ : codeBlockStyle!.lerp(other.codeBlockStyle, t),
+ errorStateStyle: errorStateStyle == null
+ ? other.errorStateStyle
+ : errorStateStyle!.lerp(other.errorStateStyle, t),
+ messageActionsStyle: messageActionsStyle == null
+ ? other.messageActionsStyle
+ : messageActionsStyle!.lerp(other.messageActionsStyle, t),
+ pillStyle: pillStyle == null
+ ? other.pillStyle
+ : pillStyle!.lerp(other.pillStyle, t),
+ suggestionStyle: suggestionStyle == null
+ ? other.suggestionStyle
+ : suggestionStyle!.lerp(other.suggestionStyle, t),
);
}
}
diff --git a/lib/src/utils/flow_menu_core.dart b/lib/src/utils/flow_menu_core.dart
index 65891e5..7a92f69 100644
--- a/lib/src/utils/flow_menu_core.dart
+++ b/lib/src/utils/flow_menu_core.dart
@@ -1,7 +1,7 @@
import 'package:material_ui/material_ui.dart';
+import '../styles/flow_menu_style.dart';
import '../theme/flow_theme.dart';
-import '../widgets/flow_menu_style.dart';
import 'flow_gradient_outline.dart';
import 'flow_menu_sheet.dart';
import 'flow_state_colors.dart';
diff --git a/lib/src/utils/flow_menu_sheet.dart b/lib/src/utils/flow_menu_sheet.dart
index c1023a0..3088224 100644
--- a/lib/src/utils/flow_menu_sheet.dart
+++ b/lib/src/utils/flow_menu_sheet.dart
@@ -1,7 +1,7 @@
import 'package:material_ui/material_ui.dart';
+import '../styles/flow_menu_style.dart';
import '../theme/flow_theme.dart';
-import '../widgets/flow_menu_style.dart';
import 'flow_circle_button.dart';
import 'flow_menu_core.dart';
diff --git a/lib/src/widgets/flow_code_block.dart b/lib/src/widgets/flow_code_block.dart
index b1cc70e..4175f0f 100644
--- a/lib/src/widgets/flow_code_block.dart
+++ b/lib/src/widgets/flow_code_block.dart
@@ -1,5 +1,6 @@
import 'package:material_ui/material_ui.dart';
+import '../styles/flow_code_block_style.dart';
import '../theme/flow_syntax_colors.dart';
import '../theme/flow_theme.dart';
import '../utils/flow_syntax_highlighter.dart';
@@ -47,6 +48,7 @@ class FlowCodeBlock extends StatefulWidget {
this.wrap = false,
this.padding,
this.borderRadius,
+ this.style,
});
/// The source, rendered verbatim.
@@ -86,6 +88,10 @@ class FlowCodeBlock extends StatefulWidget {
/// The block's corner. Defaults to the design's 12.
final BorderRadius? borderRadius;
+ /// Per-instance restyling, merged over [FlowTheme.codeBlockStyle]'s
+ /// fields; nulls fall through to the theme tokens.
+ final FlowCodeBlockStyle? style;
+
@override
State createState() => _FlowCodeBlockState();
}
@@ -195,10 +201,13 @@ class _FlowCodeBlockState extends State {
final typography = context.flowTypography;
final syntaxColors = context.flowSyntaxColors;
+ final style =
+ context.flowTheme.codeBlockStyle?.merge(widget.style) ?? widget.style;
+
final language = FlowCodeLanguage.find(widget.language);
final span = _highlight(
language,
- typography.code.copyWith(color: colors.onSurface),
+ typography.code.copyWith(color: colors.onSurface).merge(style?.codeStyle),
syntaxColors,
);
@@ -225,14 +234,18 @@ class _FlowCodeBlockState extends State {
width: double.infinity,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
- color: colors.surfaceContainerLowest,
+ color: style?.backgroundColor ?? colors.surfaceContainerLowest,
borderRadius: widget.borderRadius ?? _radius,
// The hover state lives on the edge, not the ground: the
// hairline firms from the faint rung to the ladder's deepest.
+ // A style's borderColor holds through hover unless the style
+ // names its own hover edge.
border: Border.all(
color: _hovered
- ? colors.surfaceContainerHighest
- : colors.outlineVariant,
+ ? (style?.hoverBorderColor ??
+ style?.borderColor ??
+ colors.surfaceContainerHighest)
+ : (style?.borderColor ?? colors.outlineVariant),
),
),
child: Column(
@@ -252,9 +265,9 @@ class _FlowCodeBlockState extends State {
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
- style: typography.bodySmall.copyWith(
- color: colors.onSurfaceMuted,
- ),
+ style: typography.bodySmall
+ .copyWith(color: colors.onSurfaceMuted)
+ .merge(style?.headerStyle),
),
),
if (showCopy)
diff --git a/lib/src/widgets/flow_composer.dart b/lib/src/widgets/flow_composer.dart
index eec4833..88138da 100644
--- a/lib/src/widgets/flow_composer.dart
+++ b/lib/src/widgets/flow_composer.dart
@@ -2,6 +2,7 @@ import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
import '../models/flow_attachment.dart';
+import '../styles/flow_composer_style.dart';
import '../theme/flow_theme.dart';
import '../utils/flow_circle_button.dart';
import '../utils/flow_gradient_outline.dart';
@@ -47,6 +48,7 @@ class FlowComposer extends StatefulWidget {
this.trailingActions = const [],
this.padding,
this.borderRadius,
+ this.style,
}) : assert(maxLines > 0, 'maxLines must be positive');
/// Called with the trimmed text; never with empty text.
@@ -110,6 +112,10 @@ class FlowComposer extends StatefulWidget {
/// The card's corner. Defaults to the design's 24.
final BorderRadius? borderRadius;
+ /// Per-instance restyling, merged over [FlowTheme.composerStyle]'s
+ /// fields; nulls fall through to the theme tokens.
+ final FlowComposerStyle? style;
+
@override
State createState() => _FlowComposerState();
}
@@ -205,6 +211,10 @@ class _FlowComposerState extends State {
}
}
+ /// The effective style: the widget's over the theme's, tokens beneath.
+ FlowComposerStyle? _styleOf(BuildContext context) =>
+ context.flowTheme.composerStyle?.merge(widget.style) ?? widget.style;
+
/// The theme's platform rather than the real one, like the menus' sheet
/// resolution, so hosts and tests can steer it without a device.
static bool _isMobile(BuildContext context) {
@@ -244,14 +254,19 @@ class _FlowComposerState extends State {
required Widget disc,
}) {
final colors = context.flowColors;
+ final style = _styleOf(context);
return Container(
width: _buttonFrame,
height: _buttonFrame,
alignment: Alignment.center,
decoration: ShapeDecoration(
- color: colors.surfaceBright,
+ color: style?.backgroundColor ?? colors.surfaceBright,
shape: CircleBorder(
- side: BorderSide(color: active ? colors.primary : colors.outline),
+ side: BorderSide(
+ color: active
+ ? (style?.sendBackgroundColor ?? colors.primary)
+ : colors.outline,
+ ),
),
),
child: SizedBox.square(dimension: _buttonDisc, child: disc),
@@ -260,14 +275,17 @@ class _FlowComposerState extends State {
Widget _buildSendStopButton(BuildContext context) {
final colors = context.flowColors;
+ final style = _styleOf(context);
+ final discColor = style?.sendBackgroundColor ?? colors.primary;
+ final glyphColor = style?.sendForegroundColor ?? colors.onPrimary;
if (widget.isStreaming) {
return _ringed(
context,
active: true,
disc: FlowCircleButton(
icon: Icons.stop_rounded,
- background: colors.primary,
- foreground: colors.onPrimary,
+ background: discColor,
+ foreground: glyphColor,
padding: _stopPadding,
onTap: widget.onStop,
),
@@ -283,7 +301,7 @@ class _FlowComposerState extends State {
disc: Material(
// Disabled keeps the arrow's ink and only drains the disc:
// primary gives way to the 30% disabled wash.
- color: canSend ? colors.primary : colors.onSurfaceDisabled,
+ color: canSend ? discColor : colors.onSurfaceDisabled,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
@@ -292,7 +310,7 @@ class _FlowComposerState extends State {
child: CustomPaint(
// The design's arrow is a thin stroke, not the chunky
// Material glyph.
- painter: _ArrowUpPainter(color: colors.onPrimary),
+ painter: _ArrowUpPainter(color: glyphColor),
),
),
),
@@ -308,6 +326,10 @@ class _FlowComposerState extends State {
final active = widget.enabled && (_focused || _hovered);
final radius = widget.borderRadius ?? _cardRadius;
+ final style = _styleOf(context);
+ // A style's outline flattens the default gradient to one solid color
+ // in every state, like the menu card's border override does.
+ final outline = style?.outlineColor;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
@@ -317,19 +339,23 @@ class _FlowComposerState extends State {
// never shifts as it swaps between its rest and active gradients.
foregroundPainter: FlowGradientOutlinePainter(
radius: radius,
- start: colors.onSurface.withValues(
- alpha: active ? _outlineActiveAlpha : _outlineRestAlpha,
- ),
- end: colors.onSurface.withValues(
- alpha: active ? _outlineActiveFadeAlpha : _outlineRestFadeAlpha,
- ),
+ start:
+ outline ??
+ colors.onSurface.withValues(
+ alpha: active ? _outlineActiveAlpha : _outlineRestAlpha,
+ ),
+ end:
+ outline ??
+ colors.onSurface.withValues(
+ alpha: active ? _outlineActiveFadeAlpha : _outlineRestFadeAlpha,
+ ),
),
child: Container(
decoration: BoxDecoration(
// The composer is the design's raised card: it sits above the
// page rather than tinting it, in both themes, under a
// barely-there ambient lift.
- color: colors.surfaceBright,
+ color: style?.backgroundColor ?? colors.surfaceBright,
borderRadius: radius,
boxShadow: [
BoxShadow(
@@ -374,17 +400,16 @@ class _FlowComposerState extends State {
enabled: widget.enabled,
minLines: 1,
maxLines: widget.maxLines,
- style: typography.bodyLarge.copyWith(
- height: 1.3,
- color: colors.onSurface,
- ),
+ style: typography.bodyLarge
+ .copyWith(height: 1.3, color: colors.onSurface)
+ .merge(style?.textStyle),
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: widget.placeholder,
hintStyle: typography.bodyLarge.copyWith(
height: 1.3,
- color: colors.onSurfaceMuted,
+ color: style?.hintColor ?? colors.onSurfaceMuted,
),
contentPadding: EdgeInsets.zero,
),
diff --git a/lib/src/widgets/flow_error_state.dart b/lib/src/widgets/flow_error_state.dart
index 9754432..92dba9f 100644
--- a/lib/src/widgets/flow_error_state.dart
+++ b/lib/src/widgets/flow_error_state.dart
@@ -1,5 +1,6 @@
import 'package:material_ui/material_ui.dart';
+import '../styles/flow_error_state_style.dart';
import '../theme/flow_theme.dart';
/// A failure surface: an error glyph and a host-written explanation on a
@@ -36,6 +37,7 @@ class FlowErrorState extends StatelessWidget {
this.retryLabel,
this.padding,
this.borderRadius,
+ this.style,
});
/// Host-localized headline, e.g. 'Connection error'. Null lets
@@ -60,6 +62,10 @@ class FlowErrorState extends StatelessWidget {
/// The card's corner. Defaults to the design's 12.
final BorderRadius? borderRadius;
+ /// Per-instance restyling, merged over [FlowTheme.errorStateStyle]'s
+ /// fields; nulls fall through to the theme tokens.
+ final FlowErrorStateStyle? style;
+
/// The card: the message bubble's 12px corner over the outlined
/// suggestion's 2% ink wash, edged in the error ink at 40% — a
/// translucent hairline composites correctly on the page and on a
@@ -98,12 +104,15 @@ class FlowErrorState extends StatelessWidget {
final rowText = title ?? message;
final below = title == null ? null : message;
+ final effective = context.flowTheme.errorStateStyle?.merge(style) ?? style;
+
final rowStyle = title != null
- ? typography.labelMedium.copyWith(
- fontWeight: FontWeight.w600,
- color: colors.onSurface,
- )
- : typography.bodyMedium.copyWith(color: colors.onSurfaceVariant);
+ ? typography.labelMedium
+ .copyWith(fontWeight: FontWeight.w600, color: colors.onSurface)
+ .merge(effective?.titleStyle)
+ : typography.bodyMedium
+ .copyWith(color: colors.onSurfaceVariant)
+ .merge(effective?.messageStyle);
Widget? rowLabel;
if (rowText != null) {
@@ -124,10 +133,14 @@ class FlowErrorState extends StatelessWidget {
return Container(
padding: padding ?? _cardPadding,
decoration: BoxDecoration(
- color: colors.onSurface.withValues(alpha: _groundOpacity),
+ color:
+ effective?.backgroundColor ??
+ colors.onSurface.withValues(alpha: _groundOpacity),
borderRadius: borderRadius ?? _radius,
border: Border.all(
- color: colors.error.withValues(alpha: _borderOpacity),
+ color:
+ effective?.borderColor ??
+ colors.error.withValues(alpha: _borderOpacity),
),
),
child: Column(
@@ -144,7 +157,7 @@ class FlowErrorState extends StatelessWidget {
child: Icon(
Icons.error_outline,
size: _iconSize,
- color: colors.error,
+ color: effective?.glyphColor ?? colors.error,
),
),
),
@@ -161,9 +174,9 @@ class FlowErrorState extends StatelessWidget {
liveRegion: true,
child: Text(
below,
- style: typography.bodyMedium.copyWith(
- color: colors.onSurfaceVariant,
- ),
+ style: typography.bodyMedium
+ .copyWith(color: colors.onSurfaceVariant)
+ .merge(effective?.messageStyle),
),
),
),
diff --git a/lib/src/widgets/flow_markdown.dart b/lib/src/widgets/flow_markdown.dart
index 4f68699..00b525d 100644
--- a/lib/src/widgets/flow_markdown.dart
+++ b/lib/src/widgets/flow_markdown.dart
@@ -5,6 +5,7 @@ import 'package:flutter/scheduler.dart';
import 'package:material_ui/material_ui.dart';
import '../models/flow_message_part.dart';
+import '../styles/flow_markdown_style.dart';
import '../theme/flow_colors.dart';
import '../theme/flow_theme.dart';
import '../theme/flow_typography.dart';
@@ -53,6 +54,7 @@ class FlowMarkdown extends StatefulWidget {
this.onCodeCopy,
this.copiedCodePart,
this.codeCopyTooltip,
+ this.markdownStyle,
}) : assert(charactersPerSecond > 0, 'charactersPerSecond must be positive');
/// The markdown source received so far.
@@ -66,6 +68,12 @@ class FlowMarkdown extends StatefulWidget {
/// Headings keep their own scale but follow this style's color.
final TextStyle? style;
+ /// Per-element restyling — heading cuts, the link color, the inline
+ /// code chip, quote and table inks — merged over
+ /// [FlowTheme.markdownStyle]'s fields; nulls fall through to the theme
+ /// tokens.
+ final FlowMarkdownStyle? markdownStyle;
+
/// Baseline reveal speed while streaming.
final double charactersPerSecond;
@@ -149,8 +157,13 @@ class _FlowMarkdownState extends State {
FlowColors? _cacheColors;
FlowTypography? _cacheTypography;
TextStyle? _cacheStyle;
+ FlowMarkdownStyle? _cacheMarkdownStyle;
ValueChanged? _cacheOnLinkTap;
+ /// The effective markdown style this build — the widget's over the
+ /// theme's — resolved once at the top of [build].
+ FlowMarkdownStyle? _mdStyle;
+
/// Link recognizers, keyed by their owning leaf/cell instance and run
/// index. Instance reuse across deltas keeps settled keys stable, so
/// the sweep only ever churns the streaming tail.
@@ -309,14 +322,19 @@ class _FlowMarkdownState extends State {
// keying on it would clear the cache on every delta. Only the
// null/non-null flip restyles spans; a changed closure is rebound
// onto the cached recognizers in [_markLive].
+ _mdStyle =
+ context.flowTheme.markdownStyle?.merge(widget.markdownStyle) ??
+ widget.markdownStyle;
if (!identical(colors, _cacheColors) ||
!identical(typography, _cacheTypography) ||
widget.style != _cacheStyle ||
+ _mdStyle != _cacheMarkdownStyle ||
(widget.onLinkTap == null) != (_cacheOnLinkTap == null)) {
_settledCache.clear();
_cacheColors = colors;
_cacheTypography = typography;
_cacheStyle = widget.style;
+ _cacheMarkdownStyle = _mdStyle;
}
_cacheOnLinkTap = widget.onLinkTap;
final base = typography.bodyLarge
@@ -480,9 +498,17 @@ class _FlowMarkdownState extends State {
4 => typography.bodyLargeDark,
_ => typography.bodyMediumDark,
};
- final style = scale.copyWith(
- color: level == 6 ? colors.onSurfaceVariant : base.color,
- );
+ final override = switch (level) {
+ 1 => _mdStyle?.h1Style,
+ 2 => _mdStyle?.h2Style,
+ 3 => _mdStyle?.h3Style,
+ 4 => _mdStyle?.h4Style,
+ 5 => _mdStyle?.h5Style,
+ _ => _mdStyle?.h6Style,
+ };
+ final style = scale
+ .copyWith(color: level == 6 ? colors.onSurfaceVariant : base.color)
+ .merge(override);
return _leaf(context, block, style);
case FlowMarkdownFence():
@@ -503,7 +529,10 @@ class _FlowMarkdownState extends State {
return Container(
decoration: BoxDecoration(
border: BorderDirectional(
- start: BorderSide(color: colors.outline, width: _quoteBarWidth),
+ start: BorderSide(
+ color: _mdStyle?.quoteBarColor ?? colors.outline,
+ width: _quoteBarWidth,
+ ),
),
),
padding: const EdgeInsetsDirectional.only(start: _quoteGap),
@@ -514,7 +543,9 @@ class _FlowMarkdownState extends State {
context,
children,
// Quoted material steps down to the secondary ink.
- base: base.copyWith(color: colors.onSurfaceVariant),
+ base: base.copyWith(
+ color: _mdStyle?.quoteColor ?? colors.onSurfaceVariant,
+ ),
depth: depth,
itemGap: _blockGap,
),
@@ -568,7 +599,10 @@ class _FlowMarkdownState extends State {
case FlowMarkdownRuleBlock():
return _atomic(
block,
- Container(height: _ruleThickness, color: colors.outlineVariant),
+ Container(
+ height: _ruleThickness,
+ color: _mdStyle?.ruleColor ?? colors.outlineVariant,
+ ),
);
case FlowMarkdownTable():
@@ -631,9 +665,9 @@ class _FlowMarkdownState extends State {
runs: leaf.runs,
baseStyle: style,
styleFor: (run) => _runStyle(context, run, style),
- chipFill: context.flowColors.onSurface.withValues(
- alpha: _inlineCodeWash,
- ),
+ chipFill:
+ _mdStyle?.codeChipColor ??
+ context.flowColors.onSurface.withValues(alpha: _inlineCodeWash),
isStreaming: isCursor,
charactersPerSecond: widget.charactersPerSecond,
extraBacklog: isCursor ? _pendingBeyondCursor().toDouble() : 0,
@@ -688,9 +722,13 @@ class _FlowMarkdownState extends State {
Widget _table(BuildContext context, FlowMarkdownTable table, TextStyle base) {
final colors = context.flowColors;
final typography = context.flowTypography;
- final headerStyle = typography.bodyMediumDark.copyWith(color: base.color);
+ final headerStyle = typography.bodyMediumDark
+ .copyWith(color: base.color)
+ .merge(_mdStyle?.tableHeaderStyle);
// Table material reads a step under the prose, like code does.
- final cellStyle = typography.bodyMedium.copyWith(color: base.color);
+ final cellStyle = typography.bodyMedium
+ .copyWith(color: base.color)
+ .merge(_mdStyle?.tableCellStyle);
Widget cell(FlowMarkdownTableCell cell, TextStyle style, int column) {
final alignment = switch (column < table.alignments.length
@@ -720,7 +758,11 @@ class _FlowMarkdownState extends State {
children: [
TableRow(
decoration: BoxDecoration(
- border: Border(bottom: BorderSide(color: colors.outline)),
+ border: Border(
+ bottom: BorderSide(
+ color: _mdStyle?.tableBorderColor ?? colors.outline,
+ ),
+ ),
),
children: [
for (var c = 0; c < table.header.length; c++)
@@ -733,7 +775,11 @@ class _FlowMarkdownState extends State {
? null
: BoxDecoration(
border: Border(
- bottom: BorderSide(color: colors.outlineVariant),
+ bottom: BorderSide(
+ color:
+ _mdStyle?.tableDividerColor ??
+ colors.outlineVariant,
+ ),
),
),
children: [
@@ -761,7 +807,9 @@ class _FlowMarkdownState extends State {
// The mono face at prose size. The wash paints as a rounded chip
// in FlowChipText — the span stays plain text, so wrapping and
// the reveal keep working.
- style = typography.codeInline.copyWith(color: base.color);
+ style = typography.codeInline
+ .copyWith(color: base.color)
+ .merge(_mdStyle?.inlineCodeStyle);
}
if (run.bold) style = style.copyWith(fontWeight: FontWeight.w600);
if (run.italic) style = style.copyWith(fontStyle: FontStyle.italic);
@@ -772,10 +820,8 @@ class _FlowMarkdownState extends State {
if (linked) TextDecoration.underline,
];
if (linked) {
- style = style.copyWith(
- color: colors.tertiary,
- decorationColor: colors.tertiary,
- );
+ final linkColor = _mdStyle?.linkColor ?? colors.tertiary;
+ style = style.copyWith(color: linkColor, decorationColor: linkColor);
}
if (decorations.isNotEmpty) {
style = style.copyWith(decoration: TextDecoration.combine(decorations));
@@ -790,9 +836,9 @@ class _FlowMarkdownState extends State {
required bool tappable,
}) {
final onLinkTap = widget.onLinkTap;
- final chipFill = context.flowColors.onSurface.withValues(
- alpha: _inlineCodeWash,
- );
+ final chipFill =
+ _mdStyle?.codeChipColor ??
+ context.flowColors.onSurface.withValues(alpha: _inlineCodeWash);
final spans = [];
for (var i = 0; i < runs.length; i++) {
final run = runs[i];
diff --git a/lib/src/widgets/flow_menu.dart b/lib/src/widgets/flow_menu.dart
index 9a4e56f..8c3ab9b 100644
--- a/lib/src/widgets/flow_menu.dart
+++ b/lib/src/widgets/flow_menu.dart
@@ -1,10 +1,10 @@
import 'package:material_ui/material_ui.dart';
+import '../styles/flow_menu_style.dart';
import '../theme/flow_theme.dart';
import '../utils/flow_menu_core.dart';
import '../utils/flow_menu_sheet.dart';
import '../utils/flow_state_colors.dart';
-import 'flow_menu_style.dart';
/// One entry in a [FlowMenu]: an option or a divider.
@immutable
@@ -119,6 +119,11 @@ class FlowMenu extends StatefulWidget {
}
class _FlowMenuState extends State {
+ /// The effective style: the widget's over [FlowTheme.menuStyle]'s,
+ /// tokens beneath both.
+ FlowMenuStyle? get _style =>
+ context.flowTheme.menuStyle?.merge(widget.menuStyle) ?? widget.menuStyle;
+
/// The design's trigger: an 18px glyph centered on a 32px disc, washed
/// with the ladder's 6% `surfaceContainer` rung on hover.
static const double _triggerIconSize = 18;
@@ -135,14 +140,14 @@ class _FlowMenuState extends State {
selected: option.selected,
enabled: option.enabled,
large: large,
- style: widget.menuStyle,
+ style: _style,
onTap: () => widget.onSelected?.call(option.id),
);
}
/// The anchored menu's children.
List _menuChildren(BuildContext context) {
- final style = widget.menuStyle;
+ final style = _style;
return [
for (final entry in widget.entries)
switch (entry) {
@@ -192,7 +197,7 @@ class _FlowMenuState extends State {
}
void _openSheet() {
- final style = widget.menuStyle;
+ final style = _style;
showFlowMenuSheet(
context: context,
style: style,
@@ -243,15 +248,10 @@ class _FlowMenuState extends State {
}
return MenuAnchor(
- style: flowMenuStyle(context, style: widget.menuStyle),
+ style: flowMenuStyle(context, style: _style),
menuChildren: asSheet
? const []
- : [
- FlowMenuCard(
- style: widget.menuStyle,
- children: _menuChildren(context),
- ),
- ],
+ : [FlowMenuCard(style: _style, children: _menuChildren(context))],
builder: (context, controller, _) {
Widget trigger = Material(
type: MaterialType.transparency,
diff --git a/lib/src/widgets/flow_menu_style.dart b/lib/src/widgets/flow_menu_style.dart
deleted file mode 100644
index 6184530..0000000
--- a/lib/src/widgets/flow_menu_style.dart
+++ /dev/null
@@ -1,95 +0,0 @@
-import 'package:material_ui/material_ui.dart';
-
-/// How a flow_ui menu presents when its trigger is tapped.
-enum FlowMenuPresentation {
- /// A bottom sheet on iOS and Android, an anchored menu everywhere else.
- ///
- /// Resolved against `Theme.of(context).platform`, so a host (or a test)
- /// can steer it by overriding the ambient theme's platform.
- auto,
-
- /// Always an anchored menu, hanging off the trigger.
- menu,
-
- /// Always a modal bottom sheet.
- sheet,
-}
-
-/// Host overrides for a flow_ui menu's look, on top of the theme tokens.
-///
-/// Every field is optional; null falls back to the token-derived default
-/// noted on the field. One instance covers both presentations — the anchored
-/// menu and the bottom sheet draw from the same palette:
-///
-/// ```dart
-/// FlowModelSelector(
-/// models: models,
-/// menuStyle: const FlowMenuStyle(
-/// backgroundColor: Color(0xFF102030),
-/// labelStyle: TextStyle(fontSize: 15),
-/// ),
-/// )
-/// ```
-@immutable
-class FlowMenuStyle {
- const FlowMenuStyle({
- this.backgroundColor,
- this.borderColor,
- this.separatorColor,
- this.hoverColor,
- this.labelStyle,
- this.descriptionStyle,
- this.iconColor,
- this.checkColor,
- this.accentColor,
- this.menuRadius,
- this.sheetRadius,
- this.minWidth,
- this.barrierColor,
- });
-
- /// The menu card and the sheet. Defaults to `surfaceBright`.
- final Color? backgroundColor;
-
- /// Hairline around the card and the sheet. The card's default is a
- /// gradient from ink at 20% to ink at 12%; setting this flattens it to
- /// one solid color. The sheet's default is ink at 20%.
- final Color? borderColor;
-
- /// Rule between sections. Defaults to ink at 10%.
- final Color? separatorColor;
-
- /// Row fill on hover and focus. Defaults to `surfaceContainer`.
- final Color? hoverColor;
-
- /// Merged over the default row label style (`labelMediumEmphasised` in
- /// the menu, `labelLargeEmphasised` in the sheet).
- final TextStyle? labelStyle;
-
- /// Merged over the default description style (`labelMedium` in
- /// `onSurfaceMuted`).
- final TextStyle? descriptionStyle;
-
- /// Leading row icons. Defaults to `onSurfaceVariant`.
- final Color? iconColor;
-
- /// The selected check. Defaults to `primary`.
- final Color? checkColor;
-
- /// Accented trailing values, e.g. the chosen effort on its row.
- /// Defaults to `primary`.
- final Color? accentColor;
-
- /// Corner radius of the anchored menu card. Defaults to the design's 12.
- /// The bottom sheet's top corners come from [sheetRadius].
- final BorderRadius? menuRadius;
-
- /// The bottom sheet's top corners. Defaults to the design's 24.
- final Radius? sheetRadius;
-
- /// Minimum width of the anchored menu's rows. Defaults to 220.
- final double? minWidth;
-
- /// Scrim behind the bottom sheet. Defaults to the framework's.
- final Color? barrierColor;
-}
diff --git a/lib/src/widgets/flow_message.dart b/lib/src/widgets/flow_message.dart
index a1ffcd6..9688827 100644
--- a/lib/src/widgets/flow_message.dart
+++ b/lib/src/widgets/flow_message.dart
@@ -2,13 +2,14 @@ import 'package:material_ui/material_ui.dart';
import '../models/flow_message_data.dart';
import '../models/flow_message_part.dart';
+import '../styles/flow_message_style.dart';
import '../theme/flow_theme.dart';
import 'flow_attachment_group.dart';
import 'flow_code_block.dart';
import 'flow_error_state.dart';
import 'flow_markdown.dart';
-import 'flow_thinking_indicator.dart';
import 'flow_streaming_text.dart';
+import 'flow_thinking_indicator.dart';
/// Renders a [FlowCustomPart]; return null to skip it.
///
@@ -56,6 +57,7 @@ class FlowMessage extends StatelessWidget {
this.thinkingLabel,
this.bubbleRadius,
this.bubblePadding,
+ this.style,
}) : assert(
maxBubbleWidthFraction > 0 && maxBubbleWidthFraction <= 1,
'maxBubbleWidthFraction must be in (0, 1]',
@@ -140,6 +142,10 @@ class FlowMessage extends StatelessWidget {
/// Inside the user bubble. Defaults to the design's 16/10.
final EdgeInsetsGeometry? bubblePadding;
+ /// Per-instance restyling, merged over [FlowTheme.messageStyle]'s
+ /// fields; nulls fall through to the theme tokens.
+ final FlowMessageStyle? style;
+
/// The user bubble's ground, as an alpha over the ink — the same wash the
/// design gives every tint that sits directly on the page, so it reads
/// correctly in both themes.
@@ -179,6 +185,7 @@ class FlowMessage extends StatelessWidget {
Widget _buildUser(BuildContext context) {
final colors = context.flowColors;
+ final effective = context.flowTheme.messageStyle?.merge(style) ?? style;
final bubble = Container(
padding:
@@ -188,14 +195,18 @@ class FlowMessage extends StatelessWidget {
vertical: _bubbleVerticalPadding,
),
decoration: BoxDecoration(
+ // A failed turn keeps the error treatment regardless of style.
color: _isError
? colors.errorContainer
- : colors.onSurface.withValues(alpha: _bubbleOpacity),
+ : effective?.bubbleColor ??
+ colors.onSurface.withValues(alpha: _bubbleOpacity),
borderRadius: bubbleRadius ?? _bubbleRadius,
),
child: _buildParts(
context,
- _isError ? colors.onErrorContainer : colors.onSurface,
+ _isError
+ ? colors.onErrorContainer
+ : effective?.bubbleTextColor ?? colors.onSurface,
height: _bubbleTextHeight,
),
);
diff --git a/lib/src/widgets/flow_message_actions.dart b/lib/src/widgets/flow_message_actions.dart
index 0d48a52..808c83a 100644
--- a/lib/src/widgets/flow_message_actions.dart
+++ b/lib/src/widgets/flow_message_actions.dart
@@ -1,5 +1,6 @@
import 'package:material_ui/material_ui.dart';
+import '../styles/flow_message_actions_style.dart';
import '../theme/flow_theme.dart';
import '../utils/flow_state_colors.dart';
@@ -85,6 +86,7 @@ class FlowMessageActions extends StatelessWidget {
required this.actions,
this.iconSize = 16,
this.padding,
+ this.style,
});
/// The design's strip packs the frames a hairline apart — a component
@@ -100,8 +102,14 @@ class FlowMessageActions extends StatelessWidget {
/// Around the whole row; defaults to none.
final EdgeInsetsGeometry? padding;
+ /// Per-instance restyling, merged over [FlowTheme.messageActionsStyle]'s
+ /// fields; nulls fall through to the theme tokens.
+ final FlowMessageActionsStyle? style;
+
@override
Widget build(BuildContext context) {
+ final effective =
+ context.flowTheme.messageActionsStyle?.merge(style) ?? style;
// The design's action strip: glyphs on 20px frames, packed a hairline
// step apart.
final row = Row(
@@ -109,7 +117,11 @@ class FlowMessageActions extends StatelessWidget {
children: [
for (var i = 0; i < actions.length; i++) ...[
if (i > 0) const SizedBox(width: _gap),
- _ActionButton(action: actions[i], iconSize: iconSize),
+ _ActionButton(
+ action: actions[i],
+ iconSize: iconSize,
+ style: effective,
+ ),
],
],
);
@@ -119,10 +131,15 @@ class FlowMessageActions extends StatelessWidget {
}
class _ActionButton extends StatefulWidget {
- const _ActionButton({required this.action, required this.iconSize});
+ const _ActionButton({
+ required this.action,
+ required this.iconSize,
+ this.style,
+ });
final FlowMessageAction action;
final double iconSize;
+ final FlowMessageActionsStyle? style;
@override
State<_ActionButton> createState() => _ActionButtonState();
@@ -145,15 +162,17 @@ class _ActionButtonState extends State<_ActionButton> {
// Rest at the muted ink — the design's 50% for message-action glyphs —
// lifting to full ink on hover so the affordance stays.
+ final style = widget.style;
+ final rest = style?.iconColor ?? colors.onSurfaceMuted;
final Color foreground;
if (!enabled) {
- foreground = flowDisabledColor(colors.onSurfaceMuted);
+ foreground = flowDisabledColor(rest);
} else if (action.selected) {
- foreground = colors.primary;
+ foreground = style?.selectedColor ?? colors.primary;
} else if (_hovered) {
- foreground = colors.onSurface;
+ foreground = style?.hoverIconColor ?? colors.onSurface;
} else {
- foreground = colors.onSurfaceMuted;
+ foreground = rest;
}
// Transparent Material so ink and hover fills render anywhere,
@@ -164,7 +183,7 @@ class _ActionButtonState extends State<_ActionButton> {
onTap: action.onPressed,
onHover: enabled ? (value) => setState(() => _hovered = value) : null,
borderRadius: _frameRadius,
- hoverColor: colors.surfaceContainerHigh,
+ hoverColor: style?.hoverColor ?? colors.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.all(_framePadding),
child: Icon(
diff --git a/lib/src/widgets/flow_model_selector.dart b/lib/src/widgets/flow_model_selector.dart
index b75d14b..4bbe4e5 100644
--- a/lib/src/widgets/flow_model_selector.dart
+++ b/lib/src/widgets/flow_model_selector.dart
@@ -1,10 +1,10 @@
import 'package:material_ui/material_ui.dart';
+import '../styles/flow_menu_style.dart';
import '../theme/flow_theme.dart';
import '../utils/flow_menu_core.dart';
import '../utils/flow_menu_sheet.dart';
import '../utils/flow_state_colors.dart';
-import 'flow_menu_style.dart';
/// One model choice in a [FlowModelSelector].
@immutable
@@ -147,6 +147,11 @@ class FlowModelSelector extends StatefulWidget {
}
class _FlowModelSelectorState extends State {
+ /// The effective style: the widget's over [FlowTheme.menuStyle]'s,
+ /// tokens beneath both.
+ FlowMenuStyle? get _style =>
+ context.flowTheme.menuStyle?.merge(widget.menuStyle) ?? widget.menuStyle;
+
/// The design's trigger: an 8px-radius pill padded 8/7 — 32 tall around
/// its 18px line, matching the action row's buttons — its pieces 4
/// apart.
@@ -186,7 +191,7 @@ class _FlowModelSelectorState extends State {
selected: model.id == widget.selectedId,
enabled: model.enabled,
large: large,
- style: widget.menuStyle,
+ style: _style,
onTap: () => widget.onSelected?.call(model.id),
);
}
@@ -198,7 +203,7 @@ class _FlowModelSelectorState extends State {
selected: option.id == widget.selectedEffortId,
enabled: option.enabled,
large: large,
- style: widget.menuStyle,
+ style: _style,
// In the anchored menu the row's own closeOnTap would only dismiss
// the submenu it sits in; picking an effort should close the whole
// menu, so the root controller does it. The sheet closes through the
@@ -213,7 +218,7 @@ class _FlowModelSelectorState extends State {
/// The anchored menu's children.
List _menuChildren(BuildContext context) {
- final style = widget.menuStyle;
+ final style = _style;
final effort = _selectedEffort;
return [
for (final model in widget.models) _modelRow(model, large: false),
@@ -269,7 +274,7 @@ class _FlowModelSelectorState extends State {
}
void _openSheet() {
- final style = widget.menuStyle;
+ final style = _style;
setState(() => _sheetOpen = true);
_showSheet(style).whenComplete(() {
if (mounted) setState(() => _sheetOpen = false);
@@ -347,15 +352,10 @@ class _FlowModelSelectorState extends State {
return MenuAnchor(
controller: _menuController,
- style: flowMenuStyle(context, style: widget.menuStyle),
+ style: flowMenuStyle(context, style: _style),
menuChildren: asSheet
? const []
- : [
- FlowMenuCard(
- style: widget.menuStyle,
- children: _menuChildren(context),
- ),
- ],
+ : [FlowMenuCard(style: _style, children: _menuChildren(context))],
builder: (context, controller, _) {
final active = controller.isOpen || _sheetOpen;
Widget trigger = Material(
diff --git a/lib/src/widgets/flow_pill.dart b/lib/src/widgets/flow_pill.dart
index dd26467..5d92b14 100644
--- a/lib/src/widgets/flow_pill.dart
+++ b/lib/src/widgets/flow_pill.dart
@@ -1,5 +1,6 @@
import 'package:material_ui/material_ui.dart';
+import '../styles/flow_pill_style.dart';
import '../theme/flow_theme.dart';
import '../utils/flow_state_colors.dart';
@@ -39,6 +40,7 @@ class FlowPill extends StatefulWidget {
this.enabled = true,
this.padding,
this.borderRadius,
+ this.style,
});
/// The tool's glyph — always drawn; the pill's whole identity in the
@@ -82,6 +84,10 @@ class FlowPill extends StatefulWidget {
/// The pill's corner. Defaults to the design's 8.
final BorderRadius? borderRadius;
+ /// Per-instance restyling, merged over [FlowTheme.pillStyle]'s fields;
+ /// nulls fall through to the theme tokens.
+ final FlowPillStyle? style;
+
@override
State createState() => _FlowPillState();
}
@@ -122,25 +128,27 @@ class _FlowPillState extends State {
// carry a body tap of its own.
final VoidCallback? tapAction = hasRemove ? widget.onRemove : widget.onTap;
+ final style =
+ context.flowTheme.pillStyle?.merge(widget.style) ?? widget.style;
+
// The glyph rests a step down in the secondary ink, the label in full
// ink, and the X at the muted chrome level — lifting to full ink as
// the pill is hovered. Disabling fades each from its own rest.
- final iconForeground = enabled
- ? colors.onSurfaceVariant
- : flowDisabledColor(colors.onSurfaceVariant);
+ final iconRest = style?.iconColor ?? colors.onSurfaceVariant;
+ final iconForeground = enabled ? iconRest : flowDisabledColor(iconRest);
final labelForeground = enabled
? colors.onSurface
: flowDisabledColor(colors.onSurface);
final removeRest = enabled
- ? colors.onSurfaceMuted
- : flowDisabledColor(colors.onSurfaceMuted);
+ ? (style?.removeColor ?? colors.onSurfaceMuted)
+ : flowDisabledColor(style?.removeColor ?? colors.onSurfaceMuted);
final removeForeground = _hovered && enabled
? colors.onSurface
: removeRest;
final shape = RoundedRectangleBorder(
borderRadius: widget.borderRadius ?? _radius,
- side: BorderSide(color: colors.outlineVariant),
+ side: BorderSide(color: style?.borderColor ?? colors.outlineVariant),
);
// The X absorbs the end inset so the affordance reaches the pill's
@@ -170,9 +178,9 @@ class _FlowPillState extends State {
const SizedBox(width: _gap),
Text(
widget.label,
- style: typography.labelMediumEmphasised.copyWith(
- color: labelForeground,
- ),
+ style: typography.labelMediumEmphasised
+ .copyWith(color: labelForeground)
+ .merge(style?.labelStyle),
),
],
if (hasRemove) ...[
@@ -186,7 +194,7 @@ class _FlowPillState extends State {
// One control: the hover wash spans the whole pill and a tap anywhere
// on it fires the pill's action.
Widget pill = Material(
- color: colors.surfaceContainerLow,
+ color: style?.backgroundColor ?? colors.surfaceContainerLow,
shape: shape,
clipBehavior: Clip.antiAlias,
child: SizedBox(
@@ -198,7 +206,7 @@ class _FlowPillState extends State {
onHover: enabled
? (value) => setState(() => _hovered = value)
: null,
- hoverColor: colors.surfaceContainer,
+ hoverColor: style?.hoverColor ?? colors.surfaceContainer,
child: content,
),
),
diff --git a/lib/src/widgets/flow_suggestion.dart b/lib/src/widgets/flow_suggestion.dart
index 5d05b0f..e0b0d8f 100644
--- a/lib/src/widgets/flow_suggestion.dart
+++ b/lib/src/widgets/flow_suggestion.dart
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/gestures.dart' show PointerDeviceKind;
import 'package:material_ui/material_ui.dart';
+import '../styles/flow_suggestion_style.dart';
import '../theme/flow_theme.dart';
import '../utils/flow_state_colors.dart';
@@ -34,6 +35,7 @@ class FlowSuggestion extends StatefulWidget {
this.tooltip,
this.padding,
this.borderRadius,
+ this.style,
});
/// The suggestion text, on one line — it ellipsizes rather than wrapping.
@@ -60,6 +62,10 @@ class FlowSuggestion extends StatefulWidget {
/// The row's corner. Defaults to the design's 8.
final BorderRadius? borderRadius;
+ /// Per-instance restyling, merged over [FlowTheme.suggestionStyle]'s
+ /// fields; nulls fall through to the theme tokens.
+ final FlowSuggestionStyle? style;
+
@override
State createState() => _FlowSuggestionState();
}
@@ -89,12 +95,14 @@ class _FlowSuggestionState extends State {
Widget build(BuildContext context) {
final colors = context.flowColors;
final enabled = widget.enabled && widget.onTap != null;
+ final style =
+ context.flowTheme.suggestionStyle?.merge(widget.style) ?? widget.style;
// Plain rows rest a step down; outlined rows carry full ink. Hover
// lifts both to full ink so the affordance stays.
- final restForeground = widget.outlined
- ? colors.onSurface
- : colors.onSurfaceVariant;
+ final restForeground =
+ style?.foregroundColor ??
+ (widget.outlined ? colors.onSurface : colors.onSurfaceVariant);
final Color foreground;
if (!enabled) {
foreground = flowDisabledColor(restForeground);
@@ -107,21 +115,23 @@ class _FlowSuggestionState extends State {
final shape = RoundedRectangleBorder(
borderRadius: widget.borderRadius ?? _rowRadius,
side: widget.outlined
- ? BorderSide(color: colors.outlineVariant)
+ ? BorderSide(color: style?.borderColor ?? colors.outlineVariant)
: BorderSide.none,
);
Widget row = Material(
- color: widget.outlined
- ? colors.onSurface.withValues(alpha: _outlinedFillOpacity)
- : Colors.transparent,
+ color:
+ style?.backgroundColor ??
+ (widget.outlined
+ ? colors.onSurface.withValues(alpha: _outlinedFillOpacity)
+ : Colors.transparent),
shape: shape,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: enabled ? widget.onTap : null,
onHover: enabled ? (value) => setState(() => _hovered = value) : null,
customBorder: shape,
- hoverColor: colors.surfaceContainerLow,
+ hoverColor: style?.hoverColor ?? colors.surfaceContainerLow,
child: Padding(
padding: widget.padding ?? _rowPadding,
child: SizedBox(
@@ -132,9 +142,9 @@ class _FlowSuggestionState extends State {
widget.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
- style: context.flowTypography.labelLarge.copyWith(
- color: foreground,
- ),
+ style: context.flowTypography.labelLarge
+ .copyWith(color: foreground)
+ .merge(style?.labelStyle),
);
// The hovered row in the group's column form points onward
// with an arrow at its far end — the design's web