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