From 35be6a9405153a7fc099267c9cee5312737b9bc6 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 22 Sep 2026 15:45:29 +0200 Subject: [PATCH 1/4] feat(ai): theme the composer and the suggestion chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last open roadmap item. 2.4 built the AITheme machinery but wired one component into it; every other widget resolved its colors straight from the ambient ColorScheme with no seam for a host to intervene. Adds ComposerThemeData (13 fields, covering ChatComposer and ComposerAttachmentSheet) and SuggestionsThemeData (3), alongside the existing ChartThemeData on the AITheme extension, plus ComposerTheme and SuggestionsTheme for subtree overrides. Both are InheritedThemes, so a scope above the composer carries across the Navigator to the attachment sheet that showModalBottomSheet pushes — the same reason ChartTheme is one. Every field is nullable and unset means "derive from the ambient ThemeData", so registering nothing renders exactly as before; the 471 existing tests, goldens included, pass untouched. Two resolution choices worth naming. disabledIconColor derives from the *resolved* iconColor rather than the scheme's, so a host that sets only iconColor gets a matching disabled shade instead of a dimmed onSurface that no longer relates to it. And the idle microphone takes sendButtonColor, not recordingButtonColor: it occupies the send button's slot and the two read as one control morphing in place. CodeBlockView is deliberately left out. Its two colors are already constructor parameters and deliberately independent of the ambient Theme — a codeTheme a ThemeData change could reach would undo that on purpose. Extracts the null-aware lerp helpers into src/theme/theme_lerp.dart rather than triplicating them, and points ChartThemeData and ResolvedChartTheme at the shared copies. Co-Authored-By: Claude Opus 5 --- packages/stream_chat_flutter_ai/CHANGELOG.md | 17 + packages/stream_chat_flutter_ai/README.md | 62 ++++ .../lib/src/chart/resolved_chart_theme.dart | 14 +- .../src/composer/chat_composer_factory.dart | 9 +- .../lib/src/composer/chat_composer_input.dart | 44 ++- .../src/composer/composer_action_button.dart | 4 +- .../composer/composer_attachment_sheet.dart | 25 +- .../src/composer/resolved_composer_theme.dart | 99 ++++++ .../src/composer/speech_to_text_button.dart | 7 +- .../lib/src/composer/suggestions_view.dart | 11 +- .../lib/src/theme/ai_theme.dart | 60 +++- .../lib/src/theme/components/chart_theme.dart | 45 +-- .../src/theme/components/composer_theme.dart | 297 ++++++++++++++++ .../theme/components/suggestions_theme.dart | 152 ++++++++ .../lib/src/theme/theme_lerp.dart | 41 +++ .../lib/stream_chat_flutter_ai.dart | 2 + .../test/src/theme/composer_theme_test.dart | 330 ++++++++++++++++++ .../src/theme/suggestions_theme_test.dart | 160 +++++++++ .../src/theme/themed_components_test.dart | 250 +++++++++++++ 19 files changed, 1534 insertions(+), 95 deletions(-) create mode 100644 packages/stream_chat_flutter_ai/lib/src/composer/resolved_composer_theme.dart create mode 100644 packages/stream_chat_flutter_ai/lib/src/theme/components/composer_theme.dart create mode 100644 packages/stream_chat_flutter_ai/lib/src/theme/components/suggestions_theme.dart create mode 100644 packages/stream_chat_flutter_ai/lib/src/theme/theme_lerp.dart create mode 100644 packages/stream_chat_flutter_ai/test/src/theme/composer_theme_test.dart create mode 100644 packages/stream_chat_flutter_ai/test/src/theme/suggestions_theme_test.dart create mode 100644 packages/stream_chat_flutter_ai/test/src/theme/themed_components_test.dart diff --git a/packages/stream_chat_flutter_ai/CHANGELOG.md b/packages/stream_chat_flutter_ai/CHANGELOG.md index 439da02..f2b7b3a 100644 --- a/packages/stream_chat_flutter_ai/CHANGELOG.md +++ b/packages/stream_chat_flutter_ai/CHANGELOG.md @@ -1,3 +1,20 @@ +## Unreleased + +✅ Added + +- **`ComposerThemeData` and `SuggestionsThemeData`**, joining `ChartThemeData` on the `AITheme` + extension. `ChatComposer`, `ComposerAttachmentSheet` and `AISuggestionsView` previously resolved + every color straight from the ambient `ColorScheme` with no seam for a host to intervene; they now + take the pill's fill and border, the hint style, icon and disabled-icon colors, the send, stop and + recording button fills and the glyph on them, the selected-option chip, the photo grid's selection + ring, the attachment placeholder, and the chips' fill, border and text style. Narrow either to a + subtree with `ComposerTheme` / `SuggestionsTheme`, which layer over the extension and cross a + `Navigator` the way `ChartTheme` does — so a scope above the composer reaches its attachment + sheet. + + Every field is nullable and unset means "derive from the ambient `ThemeData`", so registering + nothing renders exactly as before. + ## 0.0.1 First release of `stream_chat_flutter_ai`. diff --git a/packages/stream_chat_flutter_ai/README.md b/packages/stream_chat_flutter_ai/README.md index a676e42..220baaa 100644 --- a/packages/stream_chat_flutter_ai/README.md +++ b/packages/stream_chat_flutter_ai/README.md @@ -569,6 +569,68 @@ the agent will report a "checking external sources" state while it runs. This pa never reads the flag; to surface the state, pass your own caption for it to [`AITypingIndicatorView`](#aitypingindicatorview), whose `text` is an ordinary string. +### Theming + +Colors come from `AITheme`, a `ThemeExtension` registered on your app's `ThemeData`. It carries one +data class per component — `ChartThemeData`, `ComposerThemeData` and `SuggestionsThemeData`: + +```dart +MaterialApp( + theme: ThemeData( + colorSchemeSeed: brandBlue, + extensions: const [ + AITheme( + composerTheme: ComposerThemeData(sendButtonColor: Color(0xFF005FFF)), + suggestionsTheme: SuggestionsThemeData(backgroundColor: Color(0xFFEFF4FF)), + ), + ], + ), + ... +) +``` + +Every field on every one of them is nullable, and leaving one unset means "derive it from the +ambient `ThemeData`" — so overriding the send button leaves the pill, the border, the hint and the +attachment sheet following your app, in light and dark alike. Registering no extension at all gets +the appearance the package has always had; theming is opt-in. + +`ComposerThemeData` covers `ChatComposer` and `ComposerAttachmentSheet`: `fillColor` and +`borderColor` (the pill, the leading "+" button and the sheet's tiles — the surfaces meant to read +as one control), `hintStyle`, `iconColor` and `disabledIconColor`, `sendButtonColor`, +`stopButtonColor`, `recordingButtonColor`, `actionButtonForegroundColor` (the glyph on that filled +circle), `selectedOptionColor` and `selectedOptionForegroundColor`, `selectionColor` (the photo +grid's ring and check) and `attachmentPlaceholderColor`. `SuggestionsThemeData` has three: +`backgroundColor`, `borderColor` and `textStyle`. + +Narrow either to a subtree with `ComposerTheme` / `SuggestionsTheme`, the way `ChartTheme` does: + +```dart +ComposerTheme( + data: const ComposerThemeData(sendButtonColor: Colors.teal), + child: ChatComposer(onSendPressed: send), +) +``` + +Four things worth knowing: + +- **`ThemeData.copyWith(extensions:)` replaces the whole extension set.** If your app already + registers others, re-list them: + `theme.copyWith(extensions: [...theme.extensions.values, const AITheme()])`. +- **`disabledIconColor` derives from the *resolved* `iconColor`.** Set only `iconColor` and the + disabled states dim to match it, rather than to a dimmed `onSurface` that no longer relates. +- **The idle microphone uses `sendButtonColor`, not `recordingButtonColor`.** It occupies the send + button's slot, and the two are meant to read as one control morphing in place; + `recordingButtonColor` applies only while it is listening. +- **Nesting a scope replaces rather than layers**, the way `IconTheme` does. Use + `ComposerTheme.merge` / `SuggestionsTheme.merge` to add to an enclosing scope instead of + shadowing it. Both are `InheritedTheme`s, so like `ChartTheme` they carry across a `Navigator` — + a scope above the composer still reaches the attachment sheet that `showModalBottomSheet` pushes. + +The suggestion chips default to the same tokens as the composer's input pill, since a suggestion +row is normally docked directly above it. Recolor that surface and you want both. + +--- + ### Localization Every string the package renders itself resolves through an `AITranslations` instance. Register diff --git a/packages/stream_chat_flutter_ai/lib/src/chart/resolved_chart_theme.dart b/packages/stream_chat_flutter_ai/lib/src/chart/resolved_chart_theme.dart index 58206a9..bdd49f9 100644 --- a/packages/stream_chat_flutter_ai/lib/src/chart/resolved_chart_theme.dart +++ b/packages/stream_chat_flutter_ai/lib/src/chart/resolved_chart_theme.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter_ai/src/chart/chart_view.dart'; import 'package:stream_chat_flutter_ai/src/theme/components/chart_theme.dart'; +import 'package:stream_chat_flutter_ai/src/theme/theme_lerp.dart'; /// The heatmap ramp on a light [Brightness]: higher values darker. const _kLightScale = (low: Color(0xFFEAF2FB), mid: Color(0xFF4A90D9), high: Color(0xFF1B4F8A)); @@ -41,7 +42,7 @@ class ResolvedChartTheme { final scale = themeData.brightness == Brightness.dark ? _kDarkScale : _kLightScale; final palette = theme.seriesColors; - final axisLabel = _merge(const TextStyle(fontSize: 10), theme.axisLabelStyle); + final axisLabel = mergeTextStyle(const TextStyle(fontSize: 10), theme.axisLabelStyle); return ResolvedChartTheme._( // An empty palette would make `seriesColor` divide by zero, and cycling @@ -55,7 +56,7 @@ class ResolvedChartTheme { axisLabelStyle: axisLabel.color == null ? axisLabel.copyWith(color: colorScheme.onSurfaceVariant) : axisLabel, // The color is deliberately left unset when the host set none: the pie // builder then picks one per slice, since each slice is a different fill. - pieLabelStyle: _merge(const TextStyle(fontSize: 11, fontWeight: FontWeight.w600), theme.pieLabelStyle), + pieLabelStyle: mergeTextStyle(const TextStyle(fontSize: 11, fontWeight: FontWeight.w600), theme.pieLabelStyle), titleTextStyle: theme.titleTextStyle ?? themeData.textTheme.titleSmall?.copyWith(color: colorScheme.onSurface), gridLineColor: theme.gridLineColor ?? colorScheme.outlineVariant, heatmapLowColor: theme.heatmapLowColor ?? scale.low, @@ -108,13 +109,4 @@ class ResolvedChartTheme { /// The color for the [index]-th series, cycling when there are more series /// than colors. Color seriesColor(int index) => seriesColors[index % seriesColors.length]; - - /// Lays [override] over [base]. - /// - /// [TextStyle.merge] asserts on an `inherit: false` style, which a host is - /// free to build, so such a style replaces the base outright. - static TextStyle _merge(TextStyle base, TextStyle? override) { - if (override == null) return base; - return override.inherit ? base.merge(override) : override; - } } diff --git a/packages/stream_chat_flutter_ai/lib/src/composer/chat_composer_factory.dart b/packages/stream_chat_flutter_ai/lib/src/composer/chat_composer_factory.dart index 79516ba..af179fd 100644 --- a/packages/stream_chat_flutter_ai/lib/src/composer/chat_composer_factory.dart +++ b/packages/stream_chat_flutter_ai/lib/src/composer/chat_composer_factory.dart @@ -6,6 +6,7 @@ import 'package:stream_chat_flutter_ai/src/composer/chat_composer_controller.dar import 'package:stream_chat_flutter_ai/src/composer/chat_composer_input.dart'; import 'package:stream_chat_flutter_ai/src/composer/chat_composer_props.dart'; import 'package:stream_chat_flutter_ai/src/composer/composer_attachment_sheet.dart'; +import 'package:stream_chat_flutter_ai/src/composer/resolved_composer_theme.dart'; import 'package:stream_chat_flutter_ai/src/localization/ai_translations.dart'; /// Factory that produces the composable regions of [ChatComposer]. @@ -161,9 +162,9 @@ class _AttachmentButton extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + final theme = ResolvedComposerTheme.resolve(context); final enabled = !props.controller.isGenerating; - final iconColor = enabled ? colorScheme.onSurface : colorScheme.onSurface.withValues(alpha: 0.3); + final iconColor = theme.iconColorFor(enabled: enabled); return Tooltip( message: AITranslations.of(context).addPhotos, @@ -174,9 +175,9 @@ class _AttachmentButton extends StatelessWidget { // Same fill/border tokens as the input pill (see // `ChatComposerInput` in chat_composer_input.dart) so the two read // as one connected surface rather than mismatched colors. - color: colorScheme.surfaceContainerHigh, + color: theme.fillColor, shape: BoxShape.circle, - border: Border.all(color: colorScheme.outlineVariant), + border: Border.all(color: theme.borderColor), ), // Above the fill, so the ink splash is actually visible. child: Material( diff --git a/packages/stream_chat_flutter_ai/lib/src/composer/chat_composer_input.dart b/packages/stream_chat_flutter_ai/lib/src/composer/chat_composer_input.dart index 1e05caa..6a4db3d 100644 --- a/packages/stream_chat_flutter_ai/lib/src/composer/chat_composer_input.dart +++ b/packages/stream_chat_flutter_ai/lib/src/composer/chat_composer_input.dart @@ -11,6 +11,7 @@ import 'package:stream_chat_flutter_ai/src/composer/chat_composer_factory.dart'; import 'package:stream_chat_flutter_ai/src/composer/chat_composer_props.dart'; import 'package:stream_chat_flutter_ai/src/composer/chat_option.dart'; import 'package:stream_chat_flutter_ai/src/composer/composer_action_button.dart'; +import 'package:stream_chat_flutter_ai/src/composer/resolved_composer_theme.dart'; import 'package:stream_chat_flutter_ai/src/composer/speech_to_text_button.dart'; import 'package:stream_chat_flutter_ai/src/composer/speech_to_text_controller.dart'; import 'package:stream_chat_flutter_ai/src/localization/ai_translations.dart'; @@ -106,8 +107,7 @@ class _ChatComposerInputState extends State { } Widget _buildPill(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final borderColor = colorScheme.outlineVariant; + final theme = ResolvedComposerTheme.resolve(context); final controller = props.controller; // Hoisted: the getter hands back a fresh `List.unmodifiable` copy on every // call, and this build runs per keystroke. @@ -115,9 +115,9 @@ class _ChatComposerInputState extends State { return DecoratedBox( decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, + color: theme.fillColor, borderRadius: BorderRadius.circular(24), - border: Border.all(color: borderColor), + border: Border.all(color: theme.borderColor), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -149,11 +149,7 @@ class _ChatComposerInputState extends State { textInputAction: props.textInputAction, decoration: InputDecoration( hintText: props.hintText ?? AITranslations.of(context).composerHint, - hintStyle: TextStyle( - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.6, - ), - ), + hintStyle: theme.hintStyle, border: InputBorder.none, contentPadding: const EdgeInsets.fromLTRB(16, 12, 8, 12), ), @@ -228,7 +224,7 @@ class _TrailingControl extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + final theme = ResolvedComposerTheme.resolve(context); final translations = AITranslations.of(context); if (controller.isGenerating) { @@ -236,7 +232,7 @@ class _TrailingControl extends StatelessWidget { icon: Icons.stop_rounded, onPressed: onStop, tooltip: translations.stopGenerating, - color: colorScheme.error, + color: theme.stopButtonColor, ); } @@ -252,7 +248,7 @@ class _TrailingControl extends StatelessWidget { icon: Icons.arrow_upward_rounded, onPressed: controller.hasContent ? onSend : null, tooltip: translations.send, - color: colorScheme.primary, + color: theme.sendButtonColor, ); } } @@ -357,7 +353,7 @@ class _AttachmentThumbnailState extends State<_AttachmentThumbnail> { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + final theme = ResolvedComposerTheme.resolve(context); // Decode at thumbnail resolution instead of full camera resolution. final cacheWidth = (_size * MediaQuery.devicePixelRatioOf(context)).round(); @@ -377,17 +373,17 @@ class _AttachmentThumbnailState extends State<_AttachmentThumbnail> { builder: (context, snapshot) { if (snapshot.hasError) { return ColoredBox( - color: colorScheme.surface, + color: theme.attachmentPlaceholderColor, child: Icon( Icons.broken_image_outlined, size: 24, - color: colorScheme.onSurfaceVariant, + color: theme.iconColor, ), ); } final bytes = snapshot.data; if (bytes == null) { - return ColoredBox(color: colorScheme.surface); + return ColoredBox(color: theme.attachmentPlaceholderColor); } return Image.memory(bytes, fit: BoxFit.cover, cacheWidth: cacheWidth); }, @@ -401,9 +397,9 @@ class _AttachmentThumbnailState extends State<_AttachmentThumbnail> { width: 20, height: 20, decoration: BoxDecoration( - color: colorScheme.surface, + color: theme.attachmentPlaceholderColor, shape: BoxShape.circle, - border: Border.all(color: colorScheme.outlineVariant), + border: Border.all(color: theme.borderColor), ), // The Material sits *above* the fill so the ink splash is // visible — an InkWell wrapped around an opaque decoration @@ -419,7 +415,7 @@ class _AttachmentThumbnailState extends State<_AttachmentThumbnail> { child: Icon( Icons.close, size: 14, - color: colorScheme.onSurface, + color: theme.iconColor, ), ), ), @@ -444,14 +440,14 @@ class _SelectedOptionChip extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + final theme = ResolvedComposerTheme.resolve(context); return Padding( padding: const EdgeInsets.fromLTRB(12, 10, 12, 2), child: Align( alignment: Alignment.centerLeft, child: DecoratedBox( decoration: BoxDecoration( - color: colorScheme.primaryContainer, + color: theme.selectedOptionColor, borderRadius: BorderRadius.circular(20), ), child: Padding( @@ -460,13 +456,13 @@ class _SelectedOptionChip extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (option.icon != null) ...[ - Icon(option.icon, size: 18, color: colorScheme.onPrimaryContainer), + Icon(option.icon, size: 18, color: theme.selectedOptionForegroundColor), const SizedBox(width: 6), ], Text( option.text, style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: colorScheme.onPrimaryContainer, + color: theme.selectedOptionForegroundColor, fontWeight: FontWeight.w600, ), ), @@ -479,7 +475,7 @@ class _SelectedOptionChip extends StatelessWidget { onTap: onDismiss, child: Tooltip( message: AITranslations.of(context).clearOption(option.text), - child: Icon(Icons.close, size: 18, color: colorScheme.onPrimaryContainer), + child: Icon(Icons.close, size: 18, color: theme.selectedOptionForegroundColor), ), ), ), diff --git a/packages/stream_chat_flutter_ai/lib/src/composer/composer_action_button.dart b/packages/stream_chat_flutter_ai/lib/src/composer/composer_action_button.dart index 1c77ee7..57530ef 100644 --- a/packages/stream_chat_flutter_ai/lib/src/composer/composer_action_button.dart +++ b/packages/stream_chat_flutter_ai/lib/src/composer/composer_action_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter_ai/src/composer/resolved_composer_theme.dart'; /// A fixed-size, circular filled icon button. /// @@ -38,6 +39,7 @@ class ComposerActionButton extends StatelessWidget { @override Widget build(BuildContext context) { final enabled = onPressed != null; + final foregroundColor = ResolvedComposerTheme.resolve(context).actionButtonForegroundColor; return Tooltip( message: tooltip, child: AnimatedContainer( @@ -57,7 +59,7 @@ class ComposerActionButton extends StatelessWidget { clipBehavior: Clip.antiAlias, child: InkWell( onTap: onPressed, - child: Center(child: Icon(icon, color: Colors.white, size: 22)), + child: Center(child: Icon(icon, color: foregroundColor, size: 22)), ), ), ), diff --git a/packages/stream_chat_flutter_ai/lib/src/composer/composer_attachment_sheet.dart b/packages/stream_chat_flutter_ai/lib/src/composer/composer_attachment_sheet.dart index 9f04f04..1abdd2b 100644 --- a/packages/stream_chat_flutter_ai/lib/src/composer/composer_attachment_sheet.dart +++ b/packages/stream_chat_flutter_ai/lib/src/composer/composer_attachment_sheet.dart @@ -8,6 +8,7 @@ import 'package:photo_manager/photo_manager.dart'; import 'package:stream_chat_flutter_ai/src/composer/chat_composer_controller.dart'; import 'package:stream_chat_flutter_ai/src/composer/chat_composer_factory.dart'; import 'package:stream_chat_flutter_ai/src/composer/chat_option.dart'; +import 'package:stream_chat_flutter_ai/src/composer/resolved_composer_theme.dart'; import 'package:stream_chat_flutter_ai/src/localization/ai_translations.dart'; import 'package:stream_chat_flutter_ai/src/util/lru_cache.dart'; @@ -231,7 +232,7 @@ class _ComposerAttachmentSheetState extends State { } Widget _buildSheet(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + final theme = ResolvedComposerTheme.resolve(context); final translations = AITranslations.of(context); final chatOptions = widget.controller.chatOptions; final atCapacity = widget.controller.remainingAttachmentSlots <= 0; @@ -295,7 +296,7 @@ class _ComposerAttachmentSheetState extends State { ), ), if (chatOptions.isNotEmpty) ...[ - Divider(height: 24, color: colorScheme.outlineVariant), + Divider(height: 24, color: theme.borderColor), for (final option in chatOptions) _ChatOptionTile(option: option, onTap: () => _selectChatOption(option)), ], ], @@ -314,14 +315,14 @@ class _CameraTile extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + final theme = ResolvedComposerTheme.resolve(context); final borderRadius = BorderRadius.circular(12); - final iconColor = onTap == null ? colorScheme.onSurface.withValues(alpha: 0.3) : colorScheme.onSurface; + final iconColor = theme.iconColorFor(enabled: onTap != null); return Container( width: 72, height: 72, decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, + color: theme.fillColor, borderRadius: borderRadius, ), // Above the fill, so the ink splash is actually visible. @@ -378,7 +379,7 @@ class _RecentPhotoTileState extends State<_RecentPhotoTile> { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + final theme = ResolvedComposerTheme.resolve(context); final selected = widget.selected; return InkWell( onTap: widget.onTap, @@ -400,7 +401,7 @@ class _RecentPhotoTileState extends State<_RecentPhotoTile> { builder: (context, snapshot) { final bytes = snapshot.data; if (bytes == null) { - return ColoredBox(color: colorScheme.surfaceContainerHigh); + return ColoredBox(color: theme.fillColor); } return Image.memory(bytes, fit: BoxFit.cover); }, @@ -411,7 +412,7 @@ class _RecentPhotoTileState extends State<_RecentPhotoTile> { DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), - border: Border.all(color: colorScheme.primary, width: 3), + border: Border.all(color: theme.selectionColor, width: 3), ), ), if (selected) @@ -421,8 +422,8 @@ class _RecentPhotoTileState extends State<_RecentPhotoTile> { child: Container( width: 20, height: 20, - decoration: BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle), - child: const Icon(Icons.check, size: 14, color: Colors.white), + decoration: BoxDecoration(color: theme.selectionColor, shape: BoxShape.circle), + child: Icon(Icons.check, size: 14, color: theme.actionButtonForegroundColor), ), ), ], @@ -440,9 +441,9 @@ class _ChatOptionTile extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + final theme = ResolvedComposerTheme.resolve(context); return ListTile( - leading: option.icon != null ? Icon(option.icon, color: colorScheme.onSurface) : null, + leading: option.icon != null ? Icon(option.icon, color: theme.iconColor) : null, title: Text(option.text), subtitle: option.description != null ? Text(option.description!) : null, onTap: onTap, diff --git a/packages/stream_chat_flutter_ai/lib/src/composer/resolved_composer_theme.dart b/packages/stream_chat_flutter_ai/lib/src/composer/resolved_composer_theme.dart new file mode 100644 index 0000000..c837e72 --- /dev/null +++ b/packages/stream_chat_flutter_ai/lib/src/composer/resolved_composer_theme.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter_ai/src/theme/components/composer_theme.dart'; +import 'package:stream_chat_flutter_ai/src/theme/theme_lerp.dart'; + +/// A [ComposerThemeData] with every field resolved. +/// +/// Internal. [ComposerThemeData]'s fields are nullable, but the composer +/// widgets need a concrete value for each, so resolving once at the top of +/// `build` keeps the fallback table in one place. +@immutable +class ResolvedComposerTheme { + const ResolvedComposerTheme._({ + required this.fillColor, + required this.borderColor, + required this.hintStyle, + required this.iconColor, + required this.disabledIconColor, + required this.sendButtonColor, + required this.stopButtonColor, + required this.recordingButtonColor, + required this.actionButtonForegroundColor, + required this.selectedOptionColor, + required this.selectedOptionForegroundColor, + required this.selectionColor, + required this.attachmentPlaceholderColor, + }); + + /// Resolves the theme at [context]. + factory ResolvedComposerTheme.resolve(BuildContext context) { + final theme = ComposerTheme.of(context); + final colorScheme = Theme.of(context).colorScheme; + + final iconColor = theme.iconColor ?? colorScheme.onSurface; + + return ResolvedComposerTheme._( + fillColor: theme.fillColor ?? colorScheme.surfaceContainerHigh, + borderColor: theme.borderColor ?? colorScheme.outlineVariant, + hintStyle: mergeTextStyle( + TextStyle(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6)), + theme.hintStyle, + ), + iconColor: iconColor, + // Derived from the *resolved* icon color, not the scheme's: a host that + // sets only `iconColor` gets a matching disabled shade for free, rather + // than a dimmed `onSurface` that no longer relates to it. + disabledIconColor: theme.disabledIconColor ?? iconColor.withValues(alpha: 0.3), + sendButtonColor: theme.sendButtonColor ?? colorScheme.primary, + stopButtonColor: theme.stopButtonColor ?? colorScheme.error, + recordingButtonColor: theme.recordingButtonColor ?? colorScheme.error, + actionButtonForegroundColor: theme.actionButtonForegroundColor ?? Colors.white, + selectedOptionColor: theme.selectedOptionColor ?? colorScheme.primaryContainer, + selectedOptionForegroundColor: theme.selectedOptionForegroundColor ?? colorScheme.onPrimaryContainer, + selectionColor: theme.selectionColor ?? colorScheme.primary, + attachmentPlaceholderColor: theme.attachmentPlaceholderColor ?? colorScheme.surface, + ); + } + + /// See [ComposerThemeData.fillColor]. + final Color fillColor; + + /// See [ComposerThemeData.borderColor]. + final Color borderColor; + + /// See [ComposerThemeData.hintStyle]. Always carries a color. + final TextStyle hintStyle; + + /// See [ComposerThemeData.iconColor]. + final Color iconColor; + + /// See [ComposerThemeData.disabledIconColor]. + final Color disabledIconColor; + + /// See [ComposerThemeData.sendButtonColor]. + final Color sendButtonColor; + + /// See [ComposerThemeData.stopButtonColor]. + final Color stopButtonColor; + + /// See [ComposerThemeData.recordingButtonColor]. + final Color recordingButtonColor; + + /// See [ComposerThemeData.actionButtonForegroundColor]. + final Color actionButtonForegroundColor; + + /// See [ComposerThemeData.selectedOptionColor]. + final Color selectedOptionColor; + + /// See [ComposerThemeData.selectedOptionForegroundColor]. + final Color selectedOptionForegroundColor; + + /// See [ComposerThemeData.selectionColor]. + final Color selectionColor; + + /// See [ComposerThemeData.attachmentPlaceholderColor]. + final Color attachmentPlaceholderColor; + + /// The icon color for a control in the given enabled state. + Color iconColorFor({required bool enabled}) => enabled ? iconColor : disabledIconColor; +} diff --git a/packages/stream_chat_flutter_ai/lib/src/composer/speech_to_text_button.dart b/packages/stream_chat_flutter_ai/lib/src/composer/speech_to_text_button.dart index 39cb46e..181a8e8 100644 --- a/packages/stream_chat_flutter_ai/lib/src/composer/speech_to_text_button.dart +++ b/packages/stream_chat_flutter_ai/lib/src/composer/speech_to_text_button.dart @@ -5,6 +5,7 @@ import 'package:stream_chat_flutter_ai/src/composer/chat_composer.dart'; import 'package:stream_chat_flutter_ai/src/composer/chat_composer_controller.dart'; import 'package:stream_chat_flutter_ai/src/composer/chat_composer_factory.dart'; import 'package:stream_chat_flutter_ai/src/composer/composer_action_button.dart'; +import 'package:stream_chat_flutter_ai/src/composer/resolved_composer_theme.dart'; import 'package:stream_chat_flutter_ai/src/composer/speech_to_text_controller.dart'; import 'package:stream_chat_flutter_ai/src/localization/ai_translations.dart'; @@ -189,9 +190,11 @@ class _SpeechToTextButtonState extends State @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + final theme = ResolvedComposerTheme.resolve(context); final listening = _speech.isListening; - final color = listening ? colorScheme.error : colorScheme.primary; + // Idle, the mic occupies the send button's slot and takes its color, so the + // two read as one control morphing in place rather than two buttons. + final color = listening ? theme.recordingButtonColor : theme.sendButtonColor; // Rendered even before the recognizer has been initialized, and even when it // turns out to be unavailable (disabled, in that case). Hiding it outright diff --git a/packages/stream_chat_flutter_ai/lib/src/composer/suggestions_view.dart b/packages/stream_chat_flutter_ai/lib/src/composer/suggestions_view.dart index 93332b7..f24a1dc 100644 --- a/packages/stream_chat_flutter_ai/lib/src/composer/suggestions_view.dart +++ b/packages/stream_chat_flutter_ai/lib/src/composer/suggestions_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter_ai/src/theme/components/suggestions_theme.dart'; /// Padding inside each suggestion chip — matched when measuring text (see /// [_SuggestionChip._measureContentWidth]) so the computed width leaves @@ -59,9 +60,13 @@ class AISuggestionsView extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final theme = SuggestionsTheme.of(context); + // Merged onto the ambient DefaultTextStyle rather than used raw, so a theme + // setting only a color or weight keeps the app's font and size. The chips + // are measured with this exact style — see `_measureContentWidth`. final textStyle = DefaultTextStyle.of( context, - ).style.merge(TextStyle(color: colorScheme.onSurface)); + ).style.merge(TextStyle(color: colorScheme.onSurface)).merge(theme.textStyle); final textScaler = MediaQuery.textScalerOf(context); final textDirection = Directionality.of(context); @@ -83,8 +88,8 @@ class AISuggestionsView extends StatelessWidget { textScaler: textScaler, textDirection: textDirection, maxWidth: itemMaxWidth, - color: colorScheme.surfaceContainerHigh, - borderColor: colorScheme.outlineVariant, + color: theme.backgroundColor ?? colorScheme.surfaceContainerHigh, + borderColor: theme.borderColor ?? colorScheme.outlineVariant, onTap: () => onSuggestionSelected(suggestion), ), const SizedBox(width: 8), diff --git a/packages/stream_chat_flutter_ai/lib/src/theme/ai_theme.dart b/packages/stream_chat_flutter_ai/lib/src/theme/ai_theme.dart index f13858c..a416a12 100644 --- a/packages/stream_chat_flutter_ai/lib/src/theme/ai_theme.dart +++ b/packages/stream_chat_flutter_ai/lib/src/theme/ai_theme.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter_ai/src/theme/components/chart_theme.dart'; +import 'package:stream_chat_flutter_ai/src/theme/components/composer_theme.dart'; +import 'package:stream_chat_flutter_ai/src/theme/components/suggestions_theme.dart'; /// The package's overrides on the ambient Material theme, registered as a /// [ThemeExtension]. @@ -7,7 +9,12 @@ import 'package:stream_chat_flutter_ai/src/theme/components/chart_theme.dart'; /// ```dart /// MaterialApp( /// theme: ThemeData( -/// extensions: const [AITheme(chartTheme: ChartThemeData(seriesColors: brandPalette))], +/// extensions: const [ +/// AITheme( +/// chartTheme: ChartThemeData(seriesColors: brandPalette), +/// composerTheme: ComposerThemeData(sendButtonColor: brandBlue), +/// ), +/// ], /// ), /// ) /// ``` @@ -17,19 +24,41 @@ import 'package:stream_chat_flutter_ai/src/theme/components/chart_theme.dart'; /// replaces the whole extension set, so an app with other extensions has to /// re-list them. /// +/// Every component theme defaults to an all-null instance, which derives each +/// field from the ambient [ThemeData] — registering no extension at all gets +/// the appearance the package always had. +/// /// See also: /// -/// * [ChartThemeData], the only component theme so far, and [ChartTheme] for -/// overriding it in one subtree. +/// * [ChartThemeData] and [ChartTheme], for charts. +/// * [ComposerThemeData] and [ComposerTheme], for the composer and its +/// attachment sheet. +/// * [SuggestionsThemeData] and [SuggestionsTheme], for the suggestion chips. @immutable class AITheme extends ThemeExtension { /// Creates an [AITheme]. - const AITheme({ChartThemeData? chartTheme}) : chartTheme = chartTheme ?? const ChartThemeData(); + const AITheme({ + ChartThemeData? chartTheme, + ComposerThemeData? composerTheme, + SuggestionsThemeData? suggestionsTheme, + }) : chartTheme = chartTheme ?? const ChartThemeData(), + composerTheme = composerTheme ?? const ComposerThemeData(), + suggestionsTheme = suggestionsTheme ?? const SuggestionsThemeData(); /// How charts are drawn. Defaults to `const ChartThemeData()`, which derives /// every field from the ambient [ThemeData]. final ChartThemeData chartTheme; + /// How the composer and its attachment sheet are drawn. Defaults to + /// `const ComposerThemeData()`, which derives every field from the ambient + /// [ThemeData]. + final ComposerThemeData composerTheme; + + /// How suggestion chips are drawn. Defaults to + /// `const SuggestionsThemeData()`, which derives every field from the ambient + /// [ThemeData]. + final SuggestionsThemeData suggestionsTheme; + /// The [AITheme] on the ambient [ThemeData], or a default one. /// /// Never null and never throws — registering no extension gets the appearance @@ -37,12 +66,24 @@ class AITheme extends ThemeExtension { static AITheme of(BuildContext context) => Theme.of(context).extension() ?? const AITheme(); @override - AITheme copyWith({ChartThemeData? chartTheme}) => AITheme(chartTheme: chartTheme ?? this.chartTheme); + AITheme copyWith({ + ChartThemeData? chartTheme, + ComposerThemeData? composerTheme, + SuggestionsThemeData? suggestionsTheme, + }) => AITheme( + chartTheme: chartTheme ?? this.chartTheme, + composerTheme: composerTheme ?? this.composerTheme, + suggestionsTheme: suggestionsTheme ?? this.suggestionsTheme, + ); @override AITheme lerp(covariant AITheme? other, double t) { if (other == null || identical(this, other)) return this; - return AITheme(chartTheme: ChartThemeData.lerp(chartTheme, other.chartTheme, t)); + return AITheme( + chartTheme: ChartThemeData.lerp(chartTheme, other.chartTheme, t), + composerTheme: ComposerThemeData.lerp(composerTheme, other.composerTheme, t), + suggestionsTheme: SuggestionsThemeData.lerp(suggestionsTheme, other.suggestionsTheme, t), + ); } // Load-bearing: ThemeData.== compares extensions with mapEquals, so without @@ -51,9 +92,12 @@ class AITheme extends ThemeExtension { @override bool operator ==(Object other) { if (identical(this, other)) return true; - return other is AITheme && other.chartTheme == chartTheme; + return other is AITheme && + other.chartTheme == chartTheme && + other.composerTheme == composerTheme && + other.suggestionsTheme == suggestionsTheme; } @override - int get hashCode => chartTheme.hashCode; + int get hashCode => Object.hash(chartTheme, composerTheme, suggestionsTheme); } diff --git a/packages/stream_chat_flutter_ai/lib/src/theme/components/chart_theme.dart b/packages/stream_chat_flutter_ai/lib/src/theme/components/chart_theme.dart index 345425f..5abb21e 100644 --- a/packages/stream_chat_flutter_ai/lib/src/theme/components/chart_theme.dart +++ b/packages/stream_chat_flutter_ai/lib/src/theme/components/chart_theme.dart @@ -8,6 +8,7 @@ import 'package:stream_chat_flutter_ai/src/chart/chart_view.dart'; import 'package:stream_chat_flutter_ai/src/chart/heatmap_chart_view.dart'; import 'package:stream_chat_flutter_ai/src/chart/uspec.dart'; import 'package:stream_chat_flutter_ai/src/theme/ai_theme.dart'; +import 'package:stream_chat_flutter_ai/src/theme/theme_lerp.dart'; /// The palette [ChartView] cycles through when nothing overrides it. /// @@ -175,45 +176,29 @@ class ChartThemeData { if (identical(a, b)) return a; return ChartThemeData( seriesColors: _lerpPalette(a.seriesColors, b.seriesColors, t), - height: _lerpDouble(a.height, b.height, t), - scatterRadius: _lerpDouble(a.scatterRadius, b.scatterRadius, t), - bubbleMinRadius: _lerpDouble(a.bubbleMinRadius, b.bubbleMinRadius, t), - bubbleMaxRadius: _lerpDouble(a.bubbleMaxRadius, b.bubbleMaxRadius, t), + height: lerpDoubleOrSwap(a.height, b.height, t), + scatterRadius: lerpDoubleOrSwap(a.scatterRadius, b.scatterRadius, t), + bubbleMinRadius: lerpDoubleOrSwap(a.bubbleMinRadius, b.bubbleMinRadius, t), + bubbleMaxRadius: lerpDoubleOrSwap(a.bubbleMaxRadius, b.bubbleMaxRadius, t), // A count, so it interpolates and then rounds rather than snapping at // the midpoint. - histogramBinCount: _bothSet(a.histogramBinCount, b.histogramBinCount) + histogramBinCount: bothSet(a.histogramBinCount, b.histogramBinCount) ? lerpDouble(a.histogramBinCount, b.histogramBinCount, t)!.round() - : _swap(a.histogramBinCount, b.histogramBinCount, t), - axisLabelStyle: _lerpStyle(a.axisLabelStyle, b.axisLabelStyle, t), - pieLabelStyle: _lerpStyle(a.pieLabelStyle, b.pieLabelStyle, t), - titleTextStyle: _lerpStyle(a.titleTextStyle, b.titleTextStyle, t), - gridLineColor: _lerpColor(a.gridLineColor, b.gridLineColor, t), - heatmapLowColor: _lerpColor(a.heatmapLowColor, b.heatmapLowColor, t), - heatmapMidColor: _lerpColor(a.heatmapMidColor, b.heatmapMidColor, t), - heatmapHighColor: _lerpColor(a.heatmapHighColor, b.heatmapHighColor, t), + : swapAtMidpoint(a.histogramBinCount, b.histogramBinCount, t), + axisLabelStyle: lerpTextStyleOrSwap(a.axisLabelStyle, b.axisLabelStyle, t), + pieLabelStyle: lerpTextStyleOrSwap(a.pieLabelStyle, b.pieLabelStyle, t), + titleTextStyle: lerpTextStyleOrSwap(a.titleTextStyle, b.titleTextStyle, t), + gridLineColor: lerpColorOrSwap(a.gridLineColor, b.gridLineColor, t), + heatmapLowColor: lerpColorOrSwap(a.heatmapLowColor, b.heatmapLowColor, t), + heatmapMidColor: lerpColorOrSwap(a.heatmapMidColor, b.heatmapMidColor, t), + heatmapHighColor: lerpColorOrSwap(a.heatmapHighColor, b.heatmapHighColor, t), ); } - static bool _bothSet(Object? a, Object? b) => a != null && b != null; - - /// Picks the nearer side, for a field only one of them sets. - /// - /// Don't interpolate instead: `null` means "derive from the theme", not zero - /// or transparent, so `Color.lerp` would fade a grid line through - /// transparency and `lerpDouble` would grow the chart from zero height. - static T? _swap(T? a, T? b, double t) => t < 0.5 ? a : b; - - static double? _lerpDouble(double? a, double? b, double t) => _bothSet(a, b) ? lerpDouble(a, b, t) : _swap(a, b, t); - - static Color? _lerpColor(Color? a, Color? b, double t) => _bothSet(a, b) ? Color.lerp(a, b, t) : _swap(a, b, t); - - static TextStyle? _lerpStyle(TextStyle? a, TextStyle? b, double t) => - _bothSet(a, b) ? TextStyle.lerp(a, b, t) : _swap(a, b, t); - /// Interpolates two palettes index by index, *wrapping* the shorter one the /// way it is cycled at paint time so no series loses its color mid-animation. static List? _lerpPalette(List? a, List? b, double t) { - if (a == null || b == null || a.isEmpty || b.isEmpty) return _swap(a, b, t); + if (a == null || b == null || a.isEmpty || b.isEmpty) return swapAtMidpoint(a, b, t); if (identical(a, b)) return a; final length = a.length > b.length ? a.length : b.length; return List.generate( diff --git a/packages/stream_chat_flutter_ai/lib/src/theme/components/composer_theme.dart b/packages/stream_chat_flutter_ai/lib/src/theme/components/composer_theme.dart new file mode 100644 index 0000000..619a265 --- /dev/null +++ b/packages/stream_chat_flutter_ai/lib/src/theme/components/composer_theme.dart @@ -0,0 +1,297 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +// The composer imports are here for the doc links below only; nothing in this +// file's code uses them. +import 'package:stream_chat_flutter_ai/src/composer/chat_composer.dart'; +import 'package:stream_chat_flutter_ai/src/composer/composer_attachment_sheet.dart'; +import 'package:stream_chat_flutter_ai/src/theme/ai_theme.dart'; +import 'package:stream_chat_flutter_ai/src/theme/theme_lerp.dart'; + +/// Overrides for how [ChatComposer] and [ComposerAttachmentSheet] are drawn. +/// +/// Every field is nullable, and `null` means "derive it from the ambient +/// [ThemeData]" — so a host overrides what it cares about and the rest keeps +/// following the app, in light and dark alike. +/// +/// ```dart +/// MaterialApp( +/// theme: ThemeData( +/// extensions: const [ +/// AITheme(composerTheme: ComposerThemeData(sendButtonColor: brandBlue)), +/// ], +/// ), +/// ) +/// ``` +/// +/// Narrow it to a subtree with [ComposerTheme]. [copyWith] cannot unset a field +/// — a `null` argument keeps the current value. +/// +/// Layout stays internal: the pill's corner radius, the 40pt action-button +/// diameter, the 64pt attachment thumbnail and the 72pt sheet tiles are fixed, +/// because the composer's states have to keep occupying the same footprint as +/// they morph between mic, send and stop. +@immutable +class ComposerThemeData { + /// Creates a [ComposerThemeData]. + const ComposerThemeData({ + this.fillColor, + this.borderColor, + this.hintStyle, + this.iconColor, + this.disabledIconColor, + this.sendButtonColor, + this.stopButtonColor, + this.recordingButtonColor, + this.actionButtonForegroundColor, + this.selectedOptionColor, + this.selectedOptionForegroundColor, + this.selectionColor, + this.attachmentPlaceholderColor, + }); + + /// The fill behind the input pill, the leading "+" button and the attachment + /// sheet's tiles — the surfaces that read as one connected control. Falls + /// back to [ColorScheme.surfaceContainerHigh]. + final Color? fillColor; + + /// The hairline around the input pill, the leading button, the attachment + /// thumbnail's remove badge and the sheet's divider. Falls back to + /// [ColorScheme.outlineVariant]. + final Color? borderColor; + + /// The style of the placeholder in the text field. Falls back to + /// [ColorScheme.onSurfaceVariant] at 60% opacity. + /// + /// This is the placeholder's *appearance*; its text comes from + /// [ChatComposer.hintText] or `AITranslations.composerHint`. + final TextStyle? hintStyle; + + /// The color of the icons drawn on [fillColor]: the leading "+", the sheet's + /// tile and option icons, and an attachment thumbnail's remove "×". Falls + /// back to [ColorScheme.onSurface]. + /// + /// Not the icon inside the filled send/stop/mic circle — that is + /// [actionButtonForegroundColor], which sits on a saturated fill instead. + final Color? iconColor; + + /// The color of those same icons while their control is disabled — the + /// leading button and the sheet's tiles while the AI is generating or the + /// composer is full. Falls back to [iconColor] at 30% opacity. + final Color? disabledIconColor; + + /// The fill of the send button. Falls back to [ColorScheme.primary]. + /// + /// Dimmed to 30% by the button itself when there is nothing to send, so an + /// override does not need a second, disabled shade. + final Color? sendButtonColor; + + /// The fill of the stop button shown while the AI is generating. Falls back + /// to [ColorScheme.error]. + final Color? stopButtonColor; + + /// The fill of the microphone button while it is listening. Falls back to + /// [ColorScheme.error]. + /// + /// Idle, the microphone uses [sendButtonColor] — it occupies the send + /// button's slot, and the two read as one control morphing in place. + final Color? recordingButtonColor; + + /// The color of the icon inside the filled send/stop/mic circle. Falls back + /// to white, which is what the three default fills are legible against. + /// + /// Set this alongside a pale [sendButtonColor]; white on pale is the one + /// combination the defaults cannot anticipate. + final Color? actionButtonForegroundColor; + + /// The fill of the selected-`ChatOption` chip inside the pill. Falls back to + /// [ColorScheme.primaryContainer]. + final Color? selectedOptionColor; + + /// The text and icon color on that chip. Falls back to + /// [ColorScheme.onPrimaryContainer]. + final Color? selectedOptionForegroundColor; + + /// The ring and check badge marking a selected photo in the attachment + /// sheet's grid. Falls back to [ColorScheme.primary]. + final Color? selectionColor; + + /// The placeholder behind an attachment thumbnail that is still decoding or + /// failed to, and the fill of its remove badge. Falls back to + /// [ColorScheme.surface]. + final Color? attachmentPlaceholderColor; + + /// Returns a copy with the given fields replaced. A `null` argument keeps the + /// current value. + ComposerThemeData copyWith({ + Color? fillColor, + Color? borderColor, + TextStyle? hintStyle, + Color? iconColor, + Color? disabledIconColor, + Color? sendButtonColor, + Color? stopButtonColor, + Color? recordingButtonColor, + Color? actionButtonForegroundColor, + Color? selectedOptionColor, + Color? selectedOptionForegroundColor, + Color? selectionColor, + Color? attachmentPlaceholderColor, + }) { + return ComposerThemeData( + fillColor: fillColor ?? this.fillColor, + borderColor: borderColor ?? this.borderColor, + hintStyle: hintStyle ?? this.hintStyle, + iconColor: iconColor ?? this.iconColor, + disabledIconColor: disabledIconColor ?? this.disabledIconColor, + sendButtonColor: sendButtonColor ?? this.sendButtonColor, + stopButtonColor: stopButtonColor ?? this.stopButtonColor, + recordingButtonColor: recordingButtonColor ?? this.recordingButtonColor, + actionButtonForegroundColor: actionButtonForegroundColor ?? this.actionButtonForegroundColor, + selectedOptionColor: selectedOptionColor ?? this.selectedOptionColor, + selectedOptionForegroundColor: selectedOptionForegroundColor ?? this.selectedOptionForegroundColor, + selectionColor: selectionColor ?? this.selectionColor, + attachmentPlaceholderColor: attachmentPlaceholderColor ?? this.attachmentPlaceholderColor, + ); + } + + /// Returns this theme with [other]'s set fields layered on top — what makes a + /// partial override partial. + ComposerThemeData merge(ComposerThemeData? other) { + if (other == null || identical(this, other)) return this; + return copyWith( + fillColor: other.fillColor, + borderColor: other.borderColor, + hintStyle: other.hintStyle, + iconColor: other.iconColor, + disabledIconColor: other.disabledIconColor, + sendButtonColor: other.sendButtonColor, + stopButtonColor: other.stopButtonColor, + recordingButtonColor: other.recordingButtonColor, + actionButtonForegroundColor: other.actionButtonForegroundColor, + selectedOptionColor: other.selectedOptionColor, + selectedOptionForegroundColor: other.selectedOptionForegroundColor, + selectionColor: other.selectionColor, + attachmentPlaceholderColor: other.attachmentPlaceholderColor, + ); + } + + /// Linearly interpolates between two [ComposerThemeData]s. Runs on every + /// theme animation, including a light/dark switch. + static ComposerThemeData lerp(ComposerThemeData a, ComposerThemeData b, double t) { + if (identical(a, b)) return a; + return ComposerThemeData( + fillColor: lerpColorOrSwap(a.fillColor, b.fillColor, t), + borderColor: lerpColorOrSwap(a.borderColor, b.borderColor, t), + hintStyle: lerpTextStyleOrSwap(a.hintStyle, b.hintStyle, t), + iconColor: lerpColorOrSwap(a.iconColor, b.iconColor, t), + disabledIconColor: lerpColorOrSwap(a.disabledIconColor, b.disabledIconColor, t), + sendButtonColor: lerpColorOrSwap(a.sendButtonColor, b.sendButtonColor, t), + stopButtonColor: lerpColorOrSwap(a.stopButtonColor, b.stopButtonColor, t), + recordingButtonColor: lerpColorOrSwap(a.recordingButtonColor, b.recordingButtonColor, t), + actionButtonForegroundColor: lerpColorOrSwap(a.actionButtonForegroundColor, b.actionButtonForegroundColor, t), + selectedOptionColor: lerpColorOrSwap(a.selectedOptionColor, b.selectedOptionColor, t), + selectedOptionForegroundColor: lerpColorOrSwap( + a.selectedOptionForegroundColor, + b.selectedOptionForegroundColor, + t, + ), + selectionColor: lerpColorOrSwap(a.selectionColor, b.selectionColor, t), + attachmentPlaceholderColor: lerpColorOrSwap(a.attachmentPlaceholderColor, b.attachmentPlaceholderColor, t), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ComposerThemeData && + other.fillColor == fillColor && + other.borderColor == borderColor && + other.hintStyle == hintStyle && + other.iconColor == iconColor && + other.disabledIconColor == disabledIconColor && + other.sendButtonColor == sendButtonColor && + other.stopButtonColor == stopButtonColor && + other.recordingButtonColor == recordingButtonColor && + other.actionButtonForegroundColor == actionButtonForegroundColor && + other.selectedOptionColor == selectedOptionColor && + other.selectedOptionForegroundColor == selectedOptionForegroundColor && + other.selectionColor == selectionColor && + other.attachmentPlaceholderColor == attachmentPlaceholderColor; + } + + @override + int get hashCode => Object.hash( + fillColor, + borderColor, + hintStyle, + iconColor, + disabledIconColor, + sendButtonColor, + stopButtonColor, + recordingButtonColor, + actionButtonForegroundColor, + selectedOptionColor, + selectedOptionForegroundColor, + selectionColor, + attachmentPlaceholderColor, + ); +} + +/// Overrides the composer theme for the widgets below it. +/// +/// Layers [data] over the ambient [AITheme], so a scope setting only a send +/// color leaves everything else alone: +/// +/// ```dart +/// ComposerTheme( +/// data: const ComposerThemeData(sendButtonColor: Colors.teal), +/// child: ChatComposer(onSendPressed: send), +/// ) +/// ``` +/// +/// Being an [InheritedTheme] it reaches the attachment sheet too, which +/// `showModalBottomSheet` pushes on its own route — the same way a [Theme] +/// carries across a [Navigator]. Nesting one inside another replaces rather +/// than layers, the way [IconTheme] does; use [ComposerTheme.merge] to add to +/// an enclosing scope instead. +class ComposerTheme extends InheritedTheme { + /// Creates a [ComposerTheme]. + const ComposerTheme({super.key, required this.data, required super.child}); + + /// A scope layering [data] over the enclosing [ComposerTheme] rather than + /// replacing it. Use it wherever a scope may already be above you. + static Widget merge({Key? key, required ComposerThemeData data, required Widget child}) => Builder( + builder: (context) { + // The enclosing scope, not [of]'s result, which would make `data` a full + // snapshot of [AITheme] instead of the overrides it documents. + final outer = context.dependOnInheritedWidgetOfExactType()?.data; + return ComposerTheme(key: key, data: outer?.merge(data) ?? data, child: child); + }, + ); + + /// The overrides applied to the subtree. + final ComposerThemeData data; + + /// The composer theme at [context]: the nearest [ComposerTheme] layered over + /// [AITheme]'s. + /// + /// Depends on both, so the composer repaints when either changes. Never + /// `null`; with neither present every field derives from the ambient + /// [ThemeData]. + static ComposerThemeData of(BuildContext context) { + final local = context.dependOnInheritedWidgetOfExactType(); + return AITheme.of(context).composerTheme.merge(local?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) => ComposerTheme(data: data, child: child); + + @override + bool updateShouldNotify(ComposerTheme oldWidget) => oldWidget.data != data; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('data', data)); + } +} diff --git a/packages/stream_chat_flutter_ai/lib/src/theme/components/suggestions_theme.dart b/packages/stream_chat_flutter_ai/lib/src/theme/components/suggestions_theme.dart new file mode 100644 index 0000000..b575449 --- /dev/null +++ b/packages/stream_chat_flutter_ai/lib/src/theme/components/suggestions_theme.dart @@ -0,0 +1,152 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +// Imported for the doc links below only; nothing in this file's code uses it. +import 'package:stream_chat_flutter_ai/src/composer/suggestions_view.dart'; +import 'package:stream_chat_flutter_ai/src/theme/ai_theme.dart'; +import 'package:stream_chat_flutter_ai/src/theme/components/composer_theme.dart'; +import 'package:stream_chat_flutter_ai/src/theme/theme_lerp.dart'; + +/// Overrides for how [AISuggestionsView] draws its chips. +/// +/// Every field is nullable, and `null` means "derive it from the ambient +/// [ThemeData]" — so a host overrides what it cares about and the rest keeps +/// following the app. +/// +/// ```dart +/// MaterialApp( +/// theme: ThemeData( +/// extensions: const [ +/// AITheme(suggestionsTheme: SuggestionsThemeData(backgroundColor: brandMint)), +/// ], +/// ), +/// ) +/// ``` +/// +/// The defaults are deliberately the same tokens [ComposerThemeData] uses for +/// the input pill, since a suggestion row is normally docked directly above the +/// composer and the two should read as one surface. Override both together if +/// you are recoloring that surface. +/// +/// Narrow it to a subtree with [SuggestionsTheme]. [copyWith] cannot unset a +/// field — a `null` argument keeps the current value. +@immutable +class SuggestionsThemeData { + /// Creates a [SuggestionsThemeData]. + const SuggestionsThemeData({this.backgroundColor, this.borderColor, this.textStyle}); + + /// The fill behind a chip. Falls back to [ColorScheme.surfaceContainerHigh], + /// matching [ComposerThemeData.fillColor]'s default. + final Color? backgroundColor; + + /// The hairline around a chip. Falls back to [ColorScheme.outlineVariant], + /// matching [ComposerThemeData.borderColor]'s default. + final Color? borderColor; + + /// The style of a chip's text. Falls back to the ambient [DefaultTextStyle] + /// in [ColorScheme.onSurface]. + /// + /// Chips are measured before they are laid out — each one shrinks to fit its + /// own two lines — so this style is what that measurement uses. A style that + /// changes the metrics (size, weight, font) resizes the chips accordingly + /// rather than overflowing them. + final TextStyle? textStyle; + + /// Returns a copy with the given fields replaced. A `null` argument keeps the + /// current value. + SuggestionsThemeData copyWith({Color? backgroundColor, Color? borderColor, TextStyle? textStyle}) { + return SuggestionsThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + borderColor: borderColor ?? this.borderColor, + textStyle: textStyle ?? this.textStyle, + ); + } + + /// Returns this theme with [other]'s set fields layered on top — what makes a + /// partial override partial. + SuggestionsThemeData merge(SuggestionsThemeData? other) { + if (other == null || identical(this, other)) return this; + return copyWith( + backgroundColor: other.backgroundColor, + borderColor: other.borderColor, + textStyle: other.textStyle, + ); + } + + /// Linearly interpolates between two [SuggestionsThemeData]s. Runs on every + /// theme animation, including a light/dark switch. + static SuggestionsThemeData lerp(SuggestionsThemeData a, SuggestionsThemeData b, double t) { + if (identical(a, b)) return a; + return SuggestionsThemeData( + backgroundColor: lerpColorOrSwap(a.backgroundColor, b.backgroundColor, t), + borderColor: lerpColorOrSwap(a.borderColor, b.borderColor, t), + textStyle: lerpTextStyleOrSwap(a.textStyle, b.textStyle, t), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SuggestionsThemeData && + other.backgroundColor == backgroundColor && + other.borderColor == borderColor && + other.textStyle == textStyle; + } + + @override + int get hashCode => Object.hash(backgroundColor, borderColor, textStyle); +} + +/// Overrides the suggestions theme for the widgets below it. +/// +/// Layers [data] over the ambient [AITheme], so a scope setting only a fill +/// leaves everything else alone: +/// +/// ```dart +/// SuggestionsTheme( +/// data: const SuggestionsThemeData(backgroundColor: Colors.teal), +/// child: AISuggestionsView(suggestions: prompts, onSuggestionSelected: send), +/// ) +/// ``` +/// +/// Nesting one inside another replaces rather than layers, the way [IconTheme] +/// does; use [SuggestionsTheme.merge] to add to an enclosing scope instead. +class SuggestionsTheme extends InheritedTheme { + /// Creates a [SuggestionsTheme]. + const SuggestionsTheme({super.key, required this.data, required super.child}); + + /// A scope layering [data] over the enclosing [SuggestionsTheme] rather than + /// replacing it. Use it wherever a scope may already be above you. + static Widget merge({Key? key, required SuggestionsThemeData data, required Widget child}) => Builder( + builder: (context) { + // The enclosing scope, not [of]'s result, which would make `data` a full + // snapshot of [AITheme] instead of the overrides it documents. + final outer = context.dependOnInheritedWidgetOfExactType()?.data; + return SuggestionsTheme(key: key, data: outer?.merge(data) ?? data, child: child); + }, + ); + + /// The overrides applied to the subtree. + final SuggestionsThemeData data; + + /// The suggestions theme at [context]: the nearest [SuggestionsTheme] layered + /// over [AITheme]'s. + /// + /// Depends on both, so the chips repaint when either changes. Never `null`; + /// with neither present every field derives from the ambient [ThemeData]. + static SuggestionsThemeData of(BuildContext context) { + final local = context.dependOnInheritedWidgetOfExactType(); + return AITheme.of(context).suggestionsTheme.merge(local?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) => SuggestionsTheme(data: data, child: child); + + @override + bool updateShouldNotify(SuggestionsTheme oldWidget) => oldWidget.data != data; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('data', data)); + } +} diff --git a/packages/stream_chat_flutter_ai/lib/src/theme/theme_lerp.dart b/packages/stream_chat_flutter_ai/lib/src/theme/theme_lerp.dart new file mode 100644 index 0000000..a33e1a0 --- /dev/null +++ b/packages/stream_chat_flutter_ai/lib/src/theme/theme_lerp.dart @@ -0,0 +1,41 @@ +/// Interpolation helpers shared by the component theme datas. +/// +/// Internal. Every field on those classes is nullable, and `null` means "derive +/// it from the ambient [ThemeData]" rather than zero, transparent or empty — so +/// a field only one side sets cannot be interpolated towards, and these helpers +/// swap at the midpoint instead. +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/material.dart'; + +/// Whether both sides set a field, the only case worth interpolating. +bool bothSet(Object? a, Object? b) => a != null && b != null; + +/// Picks the nearer side, for a field only one of them sets. +/// +/// Don't interpolate instead: `null` means "derive from the theme", not zero or +/// transparent, so [Color.lerp] would fade a border through transparency and +/// [lerpDouble] would grow a box from zero. +T? swapAtMidpoint(T? a, T? b, double t) => t < 0.5 ? a : b; + +/// [lerpDouble] where both sides set the value, a swap where only one does. +double? lerpDoubleOrSwap(double? a, double? b, double t) => + bothSet(a, b) ? lerpDouble(a, b, t) : swapAtMidpoint(a, b, t); + +/// [Color.lerp] where both sides set the color, a swap where only one does. +Color? lerpColorOrSwap(Color? a, Color? b, double t) => bothSet(a, b) ? Color.lerp(a, b, t) : swapAtMidpoint(a, b, t); + +/// [TextStyle.lerp] where both sides set the style, a swap where only one does. +TextStyle? lerpTextStyleOrSwap(TextStyle? a, TextStyle? b, double t) => + bothSet(a, b) ? TextStyle.lerp(a, b, t) : swapAtMidpoint(a, b, t); + +/// Lays [override] over [base]. +/// +/// [TextStyle.merge] asserts on an `inherit: false` style, which a host is free +/// to build, so such a style replaces the base outright. +TextStyle mergeTextStyle(TextStyle base, TextStyle? override) { + if (override == null) return base; + return override.inherit ? base.merge(override) : override; +} diff --git a/packages/stream_chat_flutter_ai/lib/stream_chat_flutter_ai.dart b/packages/stream_chat_flutter_ai/lib/stream_chat_flutter_ai.dart index 4086818..23468e4 100644 --- a/packages/stream_chat_flutter_ai/lib/stream_chat_flutter_ai.dart +++ b/packages/stream_chat_flutter_ai/lib/stream_chat_flutter_ai.dart @@ -34,6 +34,8 @@ export 'src/markdown/math_syntax.dart'; export 'src/streaming_message_view.dart'; export 'src/theme/ai_theme.dart'; export 'src/theme/components/chart_theme.dart'; +export 'src/theme/components/composer_theme.dart'; +export 'src/theme/components/suggestions_theme.dart'; export 'src/tools/ai_tool_definition.dart'; export 'src/tools/ai_tool_invocation.dart'; diff --git a/packages/stream_chat_flutter_ai/test/src/theme/composer_theme_test.dart b/packages/stream_chat_flutter_ai/test/src/theme/composer_theme_test.dart new file mode 100644 index 0000000..d0990fc --- /dev/null +++ b/packages/stream_chat_flutter_ai/test/src/theme/composer_theme_test.dart @@ -0,0 +1,330 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter_ai/stream_chat_flutter_ai.dart'; + +void main() { + group('ComposerThemeData', () { + // Every field, rather than a spot-check: copyWith, merge, == and hashCode + // are four hand-written 13-line tables, and a crossed line in any of them + // is invisible until someone themes that particular field. The loop below + // is what makes a 14th field added to only three of the four fail loudly. + group('every field', () { + const base = ComposerThemeData( + fillColor: Color(0xFF111111), + borderColor: Color(0xFF222222), + hintStyle: TextStyle(fontSize: 11), + iconColor: Color(0xFF333333), + disabledIconColor: Color(0xFF444444), + sendButtonColor: Color(0xFF555555), + stopButtonColor: Color(0xFF666666), + recordingButtonColor: Color(0xFF777777), + actionButtonForegroundColor: Color(0xFF888888), + selectedOptionColor: Color(0xFF999999), + selectedOptionForegroundColor: Color(0xFFAAAAAA), + selectionColor: Color(0xFFBBBBBB), + attachmentPlaceholderColor: Color(0xFFCCCCCC), + ); + + /// One per field: how to set it to a second value, and how to read it. + final fields = + < + ({ + String name, + ComposerThemeData only, + ComposerThemeData Function() copied, + Object? Function(ComposerThemeData) read, + }) + >[ + ( + name: 'fillColor', + only: const ComposerThemeData(fillColor: Color(0xFF0000A1)), + copied: () => base.copyWith(fillColor: const Color(0xFF0000A1)), + read: (t) => t.fillColor, + ), + ( + name: 'borderColor', + only: const ComposerThemeData(borderColor: Color(0xFF0000A2)), + copied: () => base.copyWith(borderColor: const Color(0xFF0000A2)), + read: (t) => t.borderColor, + ), + ( + name: 'hintStyle', + only: const ComposerThemeData(hintStyle: TextStyle(fontSize: 99)), + copied: () => base.copyWith(hintStyle: const TextStyle(fontSize: 99)), + read: (t) => t.hintStyle, + ), + ( + name: 'iconColor', + only: const ComposerThemeData(iconColor: Color(0xFF0000A3)), + copied: () => base.copyWith(iconColor: const Color(0xFF0000A3)), + read: (t) => t.iconColor, + ), + ( + name: 'disabledIconColor', + only: const ComposerThemeData(disabledIconColor: Color(0xFF0000A4)), + copied: () => base.copyWith(disabledIconColor: const Color(0xFF0000A4)), + read: (t) => t.disabledIconColor, + ), + ( + name: 'sendButtonColor', + only: const ComposerThemeData(sendButtonColor: Color(0xFF0000A5)), + copied: () => base.copyWith(sendButtonColor: const Color(0xFF0000A5)), + read: (t) => t.sendButtonColor, + ), + ( + name: 'stopButtonColor', + only: const ComposerThemeData(stopButtonColor: Color(0xFF0000A6)), + copied: () => base.copyWith(stopButtonColor: const Color(0xFF0000A6)), + read: (t) => t.stopButtonColor, + ), + ( + name: 'recordingButtonColor', + only: const ComposerThemeData(recordingButtonColor: Color(0xFF0000A7)), + copied: () => base.copyWith(recordingButtonColor: const Color(0xFF0000A7)), + read: (t) => t.recordingButtonColor, + ), + ( + name: 'actionButtonForegroundColor', + only: const ComposerThemeData(actionButtonForegroundColor: Color(0xFF0000A8)), + copied: () => base.copyWith(actionButtonForegroundColor: const Color(0xFF0000A8)), + read: (t) => t.actionButtonForegroundColor, + ), + ( + name: 'selectedOptionColor', + only: const ComposerThemeData(selectedOptionColor: Color(0xFF0000A9)), + copied: () => base.copyWith(selectedOptionColor: const Color(0xFF0000A9)), + read: (t) => t.selectedOptionColor, + ), + ( + name: 'selectedOptionForegroundColor', + only: const ComposerThemeData(selectedOptionForegroundColor: Color(0xFF0000AA)), + copied: () => base.copyWith(selectedOptionForegroundColor: const Color(0xFF0000AA)), + read: (t) => t.selectedOptionForegroundColor, + ), + ( + name: 'selectionColor', + only: const ComposerThemeData(selectionColor: Color(0xFF0000AB)), + copied: () => base.copyWith(selectionColor: const Color(0xFF0000AB)), + read: (t) => t.selectionColor, + ), + ( + name: 'attachmentPlaceholderColor', + only: const ComposerThemeData(attachmentPlaceholderColor: Color(0xFF0000AC)), + copied: () => base.copyWith(attachmentPlaceholderColor: const Color(0xFF0000AC)), + read: (t) => t.attachmentPlaceholderColor, + ), + ]; + + test('the table covers every field the class declares', () { + // Guards the loop itself: add a field without a row here and this + // fails rather than silently leaving the new field untested. + expect(fields, hasLength(13)); + expect(fields.map((f) => f.name).toSet(), hasLength(13)); + }); + + for (final field in fields) { + test('${field.name} survives copyWith, merge, == and hashCode', () { + final copied = field.copied(); + final merged = base.merge(field.only); + + expect(field.read(copied), field.read(field.only), reason: 'copyWith set ${field.name}'); + expect(field.read(merged), field.read(field.only), reason: 'merge took ${field.name}'); + + // Nothing else moved — this is what catches a crossed line. + for (final other in fields.where((f) => f.name != field.name)) { + expect(other.read(copied), other.read(base), reason: 'copyWith(${field.name}) disturbed ${other.name}'); + expect(other.read(merged), other.read(base), reason: 'merge(${field.name}) disturbed ${other.name}'); + } + + expect(copied, merged, reason: 'copyWith and merge disagree on ${field.name}'); + expect(copied, isNot(base), reason: '== ignores ${field.name}'); + expect(copied.hashCode, isNot(base.hashCode), reason: 'hashCode ignores ${field.name}'); + }); + } + }); + + group('merge', () { + test("takes the other theme's set fields", () { + const base = ComposerThemeData(fillColor: Color(0xFF111111), sendButtonColor: Color(0xFF222222)); + final merged = base.merge(const ComposerThemeData(fillColor: Color(0xFF333333))); + + expect(merged.fillColor, const Color(0xFF333333)); + expect(merged.sendButtonColor, const Color(0xFF222222), reason: 'an unset field leaves the base value alone'); + }); + + test('with null returns this', () { + const base = ComposerThemeData(fillColor: Color(0xFF111111)); + expect(base.merge(null), same(base)); + }); + }); + + group('lerp', () { + test('interpolates a field both sides set', () { + const a = ComposerThemeData(sendButtonColor: Color(0xFF000000)); + const b = ComposerThemeData(sendButtonColor: Color(0xFFFFFFFF)); + + expect( + ComposerThemeData.lerp(a, b, 0.5).sendButtonColor, + Color.lerp(const Color(0xFF000000), const Color(0xFFFFFFFF), 0.5), + ); + }); + + test('swaps an unset field instead of fading it through transparency', () { + // `null` means "derive from the ambient ThemeData", not "transparent" — + // interpolating towards it would wash the fill out mid-animation and + // then snap back. + const set = ComposerThemeData(fillColor: Color(0xFF123456)); + const unset = ComposerThemeData(); + + expect(ComposerThemeData.lerp(set, unset, 0.25).fillColor, const Color(0xFF123456)); + expect(ComposerThemeData.lerp(set, unset, 0.75).fillColor, isNull); + }); + + test('with identical themes returns the first', () { + const theme = ComposerThemeData(fillColor: Color(0xFF111111)); + expect(ComposerThemeData.lerp(theme, theme, 0.5), same(theme)); + }); + }); + }); + + group('ComposerTheme', () { + testWidgets('resolves to an all-null theme with nothing registered', (tester) async { + late ComposerThemeData resolved; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + resolved = ComposerTheme.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(resolved, const ComposerThemeData(), reason: 'theming stays opt-in'); + }); + + testWidgets('layers a scope over the AITheme rather than replacing it', (tester) async { + late ComposerThemeData resolved; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: const [ + AITheme( + composerTheme: ComposerThemeData( + fillColor: Color(0xFF111111), + sendButtonColor: Color(0xFF222222), + ), + ), + ], + ), + home: ComposerTheme( + data: const ComposerThemeData(sendButtonColor: Color(0xFF333333)), + child: Builder( + builder: (context) { + resolved = ComposerTheme.of(context); + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(resolved.sendButtonColor, const Color(0xFF333333), reason: 'the scope wins'); + expect(resolved.fillColor, const Color(0xFF111111), reason: "the extension's other fields survive"); + }); + + testWidgets('a nested scope replaces, and merge layers', (tester) async { + late ComposerThemeData replaced; + late ComposerThemeData layered; + + await tester.pumpWidget( + MaterialApp( + home: ComposerTheme( + data: const ComposerThemeData(fillColor: Color(0xFF111111)), + child: Column( + children: [ + ComposerTheme( + data: const ComposerThemeData(sendButtonColor: Color(0xFF222222)), + child: Builder( + builder: (context) { + replaced = ComposerTheme.of(context); + return const SizedBox(); + }, + ), + ), + ComposerTheme.merge( + data: const ComposerThemeData(sendButtonColor: Color(0xFF222222)), + child: Builder( + builder: (context) { + layered = ComposerTheme.of(context); + return const SizedBox(); + }, + ), + ), + ], + ), + ), + ), + ); + + expect(replaced.fillColor, isNull, reason: 'a plain nested scope shadows the outer one'); + expect(layered.fillColor, const Color(0xFF111111), reason: 'merge keeps the outer fill'); + expect(layered.sendButtonColor, const Color(0xFF222222)); + }); + + testWidgets('crosses a route, so it reaches the attachment sheet', (tester) async { + // ComposerTheme is an InheritedTheme, and the attachment sheet is pushed + // by showModalBottomSheet onto its own route. Without the `wrap` override + // a scope directly above the composer would stop at the Navigator and the + // sheet would silently fall back to the ambient ColorScheme. + late ComposerThemeData insideRoute; + + await tester.pumpWidget( + MaterialApp( + home: ComposerTheme( + data: const ComposerThemeData(fillColor: Color(0xFF123456)), + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showModalBottomSheet( + context: context, + builder: (context) { + insideRoute = ComposerTheme.of(context); + return const SizedBox(); + }, + ), + child: const Text('open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(insideRoute.fillColor, const Color(0xFF123456)); + }); + + test('notifies only when its data changes', () { + // The contract is updateShouldNotify itself, asserted directly: a + // rebuild count through pumpWidget would measure the parent being + // replaced, which happens either way. + const a = ComposerTheme( + data: ComposerThemeData(fillColor: Color(0xFF111111)), + child: SizedBox.shrink(), + ); + const b = ComposerTheme( + data: ComposerThemeData(fillColor: Color(0xFF222222)), + child: SizedBox.shrink(), + ); + const sameAsA = ComposerTheme( + data: ComposerThemeData(fillColor: Color(0xFF111111)), + child: SizedBox.shrink(), + ); + + expect(b.updateShouldNotify(a), isTrue); + expect(sameAsA.updateShouldNotify(a), isFalse, reason: 'an equal theme should not notify dependents'); + }); + }); +} diff --git a/packages/stream_chat_flutter_ai/test/src/theme/suggestions_theme_test.dart b/packages/stream_chat_flutter_ai/test/src/theme/suggestions_theme_test.dart new file mode 100644 index 0000000..11bbccc --- /dev/null +++ b/packages/stream_chat_flutter_ai/test/src/theme/suggestions_theme_test.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter_ai/stream_chat_flutter_ai.dart'; + +void main() { + group('SuggestionsThemeData', () { + group('every field', () { + const base = SuggestionsThemeData( + backgroundColor: Color(0xFF111111), + borderColor: Color(0xFF222222), + textStyle: TextStyle(fontSize: 11), + ); + + /// One per field: how to set it to a second value, and how to read it. + final fields = + < + ({ + String name, + SuggestionsThemeData only, + SuggestionsThemeData Function() copied, + Object? Function(SuggestionsThemeData) read, + }) + >[ + ( + name: 'backgroundColor', + only: const SuggestionsThemeData(backgroundColor: Color(0xFF0000A1)), + copied: () => base.copyWith(backgroundColor: const Color(0xFF0000A1)), + read: (t) => t.backgroundColor, + ), + ( + name: 'borderColor', + only: const SuggestionsThemeData(borderColor: Color(0xFF0000A2)), + copied: () => base.copyWith(borderColor: const Color(0xFF0000A2)), + read: (t) => t.borderColor, + ), + ( + name: 'textStyle', + only: const SuggestionsThemeData(textStyle: TextStyle(fontSize: 99)), + copied: () => base.copyWith(textStyle: const TextStyle(fontSize: 99)), + read: (t) => t.textStyle, + ), + ]; + + test('the table covers every field the class declares', () { + // Guards the loop itself: add a field without a row here and this fails + // rather than silently leaving the new field untested. + expect(fields, hasLength(3)); + expect(fields.map((f) => f.name).toSet(), hasLength(3)); + }); + + for (final field in fields) { + test('${field.name} survives copyWith, merge, == and hashCode', () { + final copied = field.copied(); + final merged = base.merge(field.only); + + expect(field.read(copied), field.read(field.only), reason: 'copyWith set ${field.name}'); + expect(field.read(merged), field.read(field.only), reason: 'merge took ${field.name}'); + + for (final other in fields.where((f) => f.name != field.name)) { + expect(other.read(copied), other.read(base), reason: 'copyWith(${field.name}) disturbed ${other.name}'); + expect(other.read(merged), other.read(base), reason: 'merge(${field.name}) disturbed ${other.name}'); + } + + expect(copied, merged, reason: 'copyWith and merge disagree on ${field.name}'); + expect(copied, isNot(base), reason: '== ignores ${field.name}'); + expect(copied.hashCode, isNot(base.hashCode), reason: 'hashCode ignores ${field.name}'); + }); + } + }); + + test('merge with null returns this', () { + const base = SuggestionsThemeData(backgroundColor: Color(0xFF111111)); + expect(base.merge(null), same(base)); + }); + + test('lerp swaps an unset field instead of fading it through transparency', () { + const set = SuggestionsThemeData(backgroundColor: Color(0xFF123456)); + const unset = SuggestionsThemeData(); + + expect(SuggestionsThemeData.lerp(set, unset, 0.25).backgroundColor, const Color(0xFF123456)); + expect(SuggestionsThemeData.lerp(set, unset, 0.75).backgroundColor, isNull); + }); + + test('lerp with identical themes returns the first', () { + const theme = SuggestionsThemeData(backgroundColor: Color(0xFF111111)); + expect(SuggestionsThemeData.lerp(theme, theme, 0.5), same(theme)); + }); + }); + + group('SuggestionsTheme', () { + testWidgets('resolves to an all-null theme with nothing registered', (tester) async { + late SuggestionsThemeData resolved; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + resolved = SuggestionsTheme.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(resolved, const SuggestionsThemeData(), reason: 'theming stays opt-in'); + }); + + testWidgets('layers a scope over the AITheme rather than replacing it', (tester) async { + late SuggestionsThemeData resolved; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: const [ + AITheme( + suggestionsTheme: SuggestionsThemeData( + backgroundColor: Color(0xFF111111), + borderColor: Color(0xFF222222), + ), + ), + ], + ), + home: SuggestionsTheme( + data: const SuggestionsThemeData(borderColor: Color(0xFF333333)), + child: Builder( + builder: (context) { + resolved = SuggestionsTheme.of(context); + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(resolved.borderColor, const Color(0xFF333333), reason: 'the scope wins'); + expect(resolved.backgroundColor, const Color(0xFF111111), reason: "the extension's other fields survive"); + }); + + testWidgets('merge layers over an enclosing scope', (tester) async { + late SuggestionsThemeData layered; + await tester.pumpWidget( + MaterialApp( + home: SuggestionsTheme( + data: const SuggestionsThemeData(backgroundColor: Color(0xFF111111)), + child: SuggestionsTheme.merge( + data: const SuggestionsThemeData(borderColor: Color(0xFF222222)), + child: Builder( + builder: (context) { + layered = SuggestionsTheme.of(context); + return const SizedBox(); + }, + ), + ), + ), + ), + ); + + expect(layered.backgroundColor, const Color(0xFF111111)); + expect(layered.borderColor, const Color(0xFF222222)); + }); + }); +} diff --git a/packages/stream_chat_flutter_ai/test/src/theme/themed_components_test.dart b/packages/stream_chat_flutter_ai/test/src/theme/themed_components_test.dart new file mode 100644 index 0000000..c95fd29 --- /dev/null +++ b/packages/stream_chat_flutter_ai/test/src/theme/themed_components_test.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter_ai/stream_chat_flutter_ai.dart'; + +/// The end of the theming chain: the theme data classes are unit-tested next +/// door, but a field that resolves correctly and is then never read by the +/// widget is exactly the bug this file exists to catch. + +const _fill = Color(0xFF102030); +const _border = Color(0xFF203040); +const _send = Color(0xFF304050); +const _stop = Color(0xFF405060); +const _optionFill = Color(0xFF506070); +const _optionForeground = Color(0xFF607080); +const _actionForeground = Color(0xFF708090); +const _icon = Color(0xFF8090A0); + +const _composerTheme = ComposerThemeData( + fillColor: _fill, + borderColor: _border, + sendButtonColor: _send, + stopButtonColor: _stop, + selectedOptionColor: _optionFill, + selectedOptionForegroundColor: _optionForeground, + actionButtonForegroundColor: _actionForeground, + iconColor: _icon, +); + +Widget _app({required Widget child, AITheme? extension}) => MaterialApp( + theme: ThemeData(extensions: extension == null ? const >[] : [extension]), + home: Scaffold(body: child), +); + +/// The fill of the first [DecoratedBox] whose decoration is a [BoxDecoration] +/// carrying [color], searched under [scope]. +bool _hasBoxWithColor(WidgetTester tester, Finder scope, Color color) { + final boxes = tester.widgetList( + find.descendant(of: scope, matching: find.byType(DecoratedBox), matchRoot: true), + ); + return boxes.any((box) { + final decoration = box.decoration; + return decoration is BoxDecoration && decoration.color == color; + }); +} + +bool _hasContainerWithColor(WidgetTester tester, Color color) { + final containers = tester.widgetList(find.byType(Container)); + return containers.any((c) { + final decoration = c.decoration; + return decoration is BoxDecoration && decoration.color == color; + }); +} + +bool _hasAnimatedContainerWithColor(WidgetTester tester, Color color) { + // The send/stop/mic circle is an AnimatedContainer, whose *rendered* + // decoration lives on the state rather than the widget, so read it back + // through the element tree instead of the widget's own `decoration`. + final found = tester.widgetList(find.byType(DecoratedBox)); + return found.any((box) { + final decoration = box.decoration; + return decoration is BoxDecoration && decoration.color == color && decoration.shape == BoxShape.circle; + }); +} + +void main() { + group('ChatComposer honours ComposerThemeData', () { + testWidgets('through the AITheme extension', (tester) async { + final controller = ChatComposerController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _app( + extension: const AITheme(composerTheme: _composerTheme), + child: ChatComposer(controller: controller, onSendPressed: (_, _, _) {}), + ), + ); + + expect(_hasBoxWithColor(tester, find.byType(ChatComposer), _fill), isTrue, reason: 'the input pill takes fill'); + expect(_hasContainerWithColor(tester, _fill), isTrue, reason: 'the leading "+" button takes the same fill'); + }); + + testWidgets('through a ComposerTheme scope', (tester) async { + final controller = ChatComposerController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _app( + child: ComposerTheme( + data: _composerTheme, + child: ChatComposer(controller: controller, onSendPressed: (_, _, _) {}), + ), + ), + ); + + expect(_hasBoxWithColor(tester, find.byType(ChatComposer), _fill), isTrue); + }); + + testWidgets('sends in sendButtonColor and stops in stopButtonColor', (tester) async { + final controller = ChatComposerController(initialText: 'hi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _app( + extension: const AITheme(composerTheme: _composerTheme), + child: ChatComposer( + controller: controller, + onSendPressed: (_, _, _) {}, + onStopPressed: () {}, + ), + ), + ); + + expect(_hasAnimatedContainerWithColor(tester, _send), isTrue, reason: 'send takes sendButtonColor'); + + controller.isGenerating = true; + await tester.pumpAndSettle(); + + expect(_hasAnimatedContainerWithColor(tester, _stop), isTrue, reason: 'stop takes stopButtonColor'); + expect( + _hasAnimatedContainerWithColor(tester, _send), + isFalse, + reason: 'the send fill is gone once generating', + ); + }); + + testWidgets('paints the action icon in actionButtonForegroundColor', (tester) async { + final controller = ChatComposerController(initialText: 'hi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _app( + extension: const AITheme(composerTheme: _composerTheme), + child: ChatComposer(controller: controller, onSendPressed: (_, _, _) {}), + ), + ); + + final sendIcon = tester.widget(find.byIcon(Icons.arrow_upward_rounded)); + expect(sendIcon.color, _actionForeground); + }); + + testWidgets('paints the selected-option chip in its own colors', (tester) async { + final controller = ChatComposerController() + ..selectChatOption(const ChatOption(id: 'a', text: 'Summarize', icon: Icons.summarize)); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _app( + extension: const AITheme(composerTheme: _composerTheme), + child: ChatComposer(controller: controller, onSendPressed: (_, _, _) {}), + ), + ); + + expect(_hasBoxWithColor(tester, find.byType(ChatComposer), _optionFill), isTrue, reason: 'the chip fill'); + + final optionIcon = tester.widget(find.byIcon(Icons.summarize)); + expect(optionIcon.color, _optionForeground); + + final label = tester.widget(find.text('Summarize')); + expect(label.style?.color, _optionForeground); + }); + + testWidgets('dims the leading button to disabledIconColor while generating', (tester) async { + final controller = ChatComposerController()..isGenerating = true; + addTearDown(controller.dispose); + + await tester.pumpWidget( + _app( + extension: const AITheme(composerTheme: _composerTheme), + child: ChatComposer( + controller: controller, + onSendPressed: (_, _, _) {}, + onStopPressed: () {}, + ), + ), + ); + + final addIcon = tester.widget(find.byIcon(Icons.add)); + // Not set on the theme, so it derives from the *resolved* iconColor — a + // host setting only `iconColor` gets a matching disabled shade for free. + expect(addIcon.color, _icon.withValues(alpha: 0.3)); + }); + + testWidgets('leaves the appearance alone when nothing is registered', (tester) async { + final controller = ChatComposerController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _app( + child: ChatComposer(controller: controller, onSendPressed: (_, _, _) {}), + ), + ); + + final scheme = ThemeData().colorScheme; + expect( + _hasBoxWithColor(tester, find.byType(ChatComposer), scheme.surfaceContainerHigh), + isTrue, + reason: 'the untouched default is still ColorScheme.surfaceContainerHigh', + ); + }); + }); + + group('AISuggestionsView honours SuggestionsThemeData', () { + testWidgets('takes the background and border from the extension', (tester) async { + await tester.pumpWidget( + _app( + extension: const AITheme( + suggestionsTheme: SuggestionsThemeData(backgroundColor: _fill, borderColor: _border), + ), + child: AISuggestionsView(suggestions: const ['Tell me a joke'], onSuggestionSelected: (_) {}), + ), + ); + + final boxes = tester.widgetList(find.byType(DecoratedBox)); + final chip = boxes.map((b) => b.decoration).whereType().firstWhere((d) => d.color == _fill); + + expect(chip.color, _fill); + expect((chip.border as Border?)?.top.color, _border); + }); + + testWidgets('merges textStyle onto the ambient DefaultTextStyle', (tester) async { + await tester.pumpWidget( + _app( + extension: const AITheme( + suggestionsTheme: SuggestionsThemeData(textStyle: TextStyle(fontWeight: FontWeight.bold)), + ), + child: AISuggestionsView(suggestions: const ['Tell me a joke'], onSuggestionSelected: (_) {}), + ), + ); + + final text = tester.widget(find.text('Tell me a joke')); + expect(text.style?.fontWeight, FontWeight.bold, reason: 'the theme weight applies'); + expect(text.style?.color, isNotNull, reason: 'a partial override keeps the derived color'); + }); + + testWidgets('leaves the appearance alone when nothing is registered', (tester) async { + await tester.pumpWidget( + _app( + child: AISuggestionsView(suggestions: const ['Tell me a joke'], onSuggestionSelected: (_) {}), + ), + ); + + final scheme = ThemeData().colorScheme; + final boxes = tester.widgetList(find.byType(DecoratedBox)); + expect( + boxes.map((b) => b.decoration).whereType().any((d) => d.color == scheme.surfaceContainerHigh), + isTrue, + ); + }); + }); +} From 943ef1b27f055717e2b91701b2d6898c05e769d9 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 23 Sep 2026 10:04:42 +0200 Subject: [PATCH 2/4] chore(ai): mark the package-private types @internal ResolvedComposerTheme, ResolvedChartTheme, LruCache and the theme_lerp helpers were documented as internal but only by convention: nothing stopped the barrel from exporting them. With @internal, an accidental export is an invalid_export_of_internal_element warning, which --fatal-infos fails. These are exactly the lib/src members the barrel doesn't export, so the annotation now matches reality rather than changing it. Tests inside the package can still import them. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/chart/resolved_chart_theme.dart | 2 ++ .../lib/src/composer/resolved_composer_theme.dart | 2 ++ .../stream_chat_flutter_ai/lib/src/theme/theme_lerp.dart | 7 +++++++ .../stream_chat_flutter_ai/lib/src/util/lru_cache.dart | 3 +++ 4 files changed, 14 insertions(+) diff --git a/packages/stream_chat_flutter_ai/lib/src/chart/resolved_chart_theme.dart b/packages/stream_chat_flutter_ai/lib/src/chart/resolved_chart_theme.dart index bdd49f9..258b2ed 100644 --- a/packages/stream_chat_flutter_ai/lib/src/chart/resolved_chart_theme.dart +++ b/packages/stream_chat_flutter_ai/lib/src/chart/resolved_chart_theme.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter_ai/src/chart/chart_view.dart'; import 'package:stream_chat_flutter_ai/src/theme/components/chart_theme.dart'; @@ -16,6 +17,7 @@ const _kDarkScale = (low: Color(0xFF12283F), mid: Color(0xFF3B7CB8), high: Color /// a concrete value for each, so resolving once at the top of `build` keeps the /// fallback table in one place. @immutable +@internal class ResolvedChartTheme { const ResolvedChartTheme._({ required this.seriesColors, diff --git a/packages/stream_chat_flutter_ai/lib/src/composer/resolved_composer_theme.dart b/packages/stream_chat_flutter_ai/lib/src/composer/resolved_composer_theme.dart index c837e72..20cb099 100644 --- a/packages/stream_chat_flutter_ai/lib/src/composer/resolved_composer_theme.dart +++ b/packages/stream_chat_flutter_ai/lib/src/composer/resolved_composer_theme.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter_ai/src/theme/components/composer_theme.dart'; import 'package:stream_chat_flutter_ai/src/theme/theme_lerp.dart'; @@ -8,6 +9,7 @@ import 'package:stream_chat_flutter_ai/src/theme/theme_lerp.dart'; /// widgets need a concrete value for each, so resolving once at the top of /// `build` keeps the fallback table in one place. @immutable +@internal class ResolvedComposerTheme { const ResolvedComposerTheme._({ required this.fillColor, diff --git a/packages/stream_chat_flutter_ai/lib/src/theme/theme_lerp.dart b/packages/stream_chat_flutter_ai/lib/src/theme/theme_lerp.dart index a33e1a0..6c60bed 100644 --- a/packages/stream_chat_flutter_ai/lib/src/theme/theme_lerp.dart +++ b/packages/stream_chat_flutter_ai/lib/src/theme/theme_lerp.dart @@ -8,9 +8,11 @@ library; import 'dart:ui' show lerpDouble; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// Whether both sides set a field, the only case worth interpolating. +@internal bool bothSet(Object? a, Object? b) => a != null && b != null; /// Picks the nearer side, for a field only one of them sets. @@ -18,16 +20,20 @@ bool bothSet(Object? a, Object? b) => a != null && b != null; /// Don't interpolate instead: `null` means "derive from the theme", not zero or /// transparent, so [Color.lerp] would fade a border through transparency and /// [lerpDouble] would grow a box from zero. +@internal T? swapAtMidpoint(T? a, T? b, double t) => t < 0.5 ? a : b; /// [lerpDouble] where both sides set the value, a swap where only one does. +@internal double? lerpDoubleOrSwap(double? a, double? b, double t) => bothSet(a, b) ? lerpDouble(a, b, t) : swapAtMidpoint(a, b, t); /// [Color.lerp] where both sides set the color, a swap where only one does. +@internal Color? lerpColorOrSwap(Color? a, Color? b, double t) => bothSet(a, b) ? Color.lerp(a, b, t) : swapAtMidpoint(a, b, t); /// [TextStyle.lerp] where both sides set the style, a swap where only one does. +@internal TextStyle? lerpTextStyleOrSwap(TextStyle? a, TextStyle? b, double t) => bothSet(a, b) ? TextStyle.lerp(a, b, t) : swapAtMidpoint(a, b, t); @@ -35,6 +41,7 @@ TextStyle? lerpTextStyleOrSwap(TextStyle? a, TextStyle? b, double t) => /// /// [TextStyle.merge] asserts on an `inherit: false` style, which a host is free /// to build, so such a style replaces the base outright. +@internal TextStyle mergeTextStyle(TextStyle base, TextStyle? override) { if (override == null) return base; return override.inherit ? base.merge(override) : override; diff --git a/packages/stream_chat_flutter_ai/lib/src/util/lru_cache.dart b/packages/stream_chat_flutter_ai/lib/src/util/lru_cache.dart index 0dc6bc6..0c20d05 100644 --- a/packages/stream_chat_flutter_ai/lib/src/util/lru_cache.dart +++ b/packages/stream_chat_flutter_ai/lib/src/util/lru_cache.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; + /// A fixed-capacity cache that evicts the *least recently used* entry. /// /// Internal to this package, and deliberately minimal: [get], [peek], [set] and @@ -12,6 +14,7 @@ /// are exactly the entries the cache exists to keep. Touching an entry on /// every hit inverts that — a snapshot is never looked up twice, so it ages out /// on its own while anything still being read stays resident. +@internal class LruCache { /// Creates a cache holding at most [capacity] entries. LruCache(this.capacity) : assert(capacity > 0, 'capacity must be at least 1'); From 498363dec1f932b179806d45ce34d1fd4878ad93 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 23 Sep 2026 10:15:58 +0200 Subject: [PATCH 3/4] fix(ai): address review feedback on composer theming - Keep the broken-thumbnail glyph on onSurfaceVariant by default, so an unthemed composer really renders as before; a host iconColor still reaches it. - Rename ComposerThemeData.selectionColor to photoSelectionColor, which no longer reads as TextSelectionThemeData's text highlight. - Resolve a hint color even under an inherit: false hintStyle, and scale iconColor's alpha for the derived disabled shade instead of replacing it. - Make the three theme datas Diagnosticable. - Correct field docs that missed surfaces they color, and the mergeTextStyle rationale, which misdescribed TextStyle.merge. - Test AITheme's composer/suggestions wiring, lerp field by field, and each field in the widget that draws it (pill, mic, attachment sheet, thumbnails, hint), with targeted finders rather than colour scans. Co-Authored-By: Claude Opus 5.5 --- packages/stream_chat_flutter_ai/CHANGELOG.md | 10 +- packages/stream_chat_flutter_ai/README.md | 2 +- .../lib/src/composer/chat_composer_input.dart | 2 +- .../composer/composer_attachment_sheet.dart | 4 +- .../src/composer/resolved_composer_theme.dart | 34 +- .../lib/src/theme/components/chart_theme.dart | 21 +- .../src/theme/components/composer_theme.dart | 66 ++-- .../theme/components/suggestions_theme.dart | 16 +- .../lib/src/theme/theme_lerp.dart | 12 +- .../src/composer/speech_to_text_test.dart | 40 +++ .../test/src/theme/ai_theme_test.dart | 55 ++++ .../test/src/theme/composer_theme_test.dart | 39 ++- .../src/theme/suggestions_theme_test.dart | 23 ++ .../src/theme/themed_components_test.dart | 304 ++++++++++++++++-- 14 files changed, 540 insertions(+), 88 deletions(-) diff --git a/packages/stream_chat_flutter_ai/CHANGELOG.md b/packages/stream_chat_flutter_ai/CHANGELOG.md index f2b7b3a..8a6d252 100644 --- a/packages/stream_chat_flutter_ai/CHANGELOG.md +++ b/packages/stream_chat_flutter_ai/CHANGELOG.md @@ -3,11 +3,11 @@ ✅ Added - **`ComposerThemeData` and `SuggestionsThemeData`**, joining `ChartThemeData` on the `AITheme` - extension. `ChatComposer`, `ComposerAttachmentSheet` and `AISuggestionsView` previously resolved - every color straight from the ambient `ColorScheme` with no seam for a host to intervene; they now - take the pill's fill and border, the hint style, icon and disabled-icon colors, the send, stop and - recording button fills and the glyph on them, the selected-option chip, the photo grid's selection - ring, the attachment placeholder, and the chips' fill, border and text style. Narrow either to a + extension. `ChatComposer`, `ComposerAttachmentSheet` and `AISuggestionsView` previously took every + color from the ambient `ColorScheme` or hard-coded white, with no seam for a host to intervene; + they now take the pill's fill and border, the hint style, icon and disabled-icon colors, the send, + stop and recording button fills and the glyph on them, the selected-option chip, the photo grid's + selection ring, the attachment placeholder, and the chips' fill, border and text style. Narrow either to a subtree with `ComposerTheme` / `SuggestionsTheme`, which layer over the extension and cross a `Navigator` the way `ChartTheme` does — so a scope above the composer reaches its attachment sheet. diff --git a/packages/stream_chat_flutter_ai/README.md b/packages/stream_chat_flutter_ai/README.md index 220baaa..5447720 100644 --- a/packages/stream_chat_flutter_ai/README.md +++ b/packages/stream_chat_flutter_ai/README.md @@ -598,7 +598,7 @@ the appearance the package has always had; theming is opt-in. `borderColor` (the pill, the leading "+" button and the sheet's tiles — the surfaces meant to read as one control), `hintStyle`, `iconColor` and `disabledIconColor`, `sendButtonColor`, `stopButtonColor`, `recordingButtonColor`, `actionButtonForegroundColor` (the glyph on that filled -circle), `selectedOptionColor` and `selectedOptionForegroundColor`, `selectionColor` (the photo +circle), `selectedOptionColor` and `selectedOptionForegroundColor`, `photoSelectionColor` (the photo grid's ring and check) and `attachmentPlaceholderColor`. `SuggestionsThemeData` has three: `backgroundColor`, `borderColor` and `textStyle`. diff --git a/packages/stream_chat_flutter_ai/lib/src/composer/chat_composer_input.dart b/packages/stream_chat_flutter_ai/lib/src/composer/chat_composer_input.dart index 6a4db3d..a54f2fb 100644 --- a/packages/stream_chat_flutter_ai/lib/src/composer/chat_composer_input.dart +++ b/packages/stream_chat_flutter_ai/lib/src/composer/chat_composer_input.dart @@ -377,7 +377,7 @@ class _AttachmentThumbnailState extends State<_AttachmentThumbnail> { child: Icon( Icons.broken_image_outlined, size: 24, - color: theme.iconColor, + color: theme.brokenAttachmentIconColor, ), ); } diff --git a/packages/stream_chat_flutter_ai/lib/src/composer/composer_attachment_sheet.dart b/packages/stream_chat_flutter_ai/lib/src/composer/composer_attachment_sheet.dart index 1abdd2b..fd10da3 100644 --- a/packages/stream_chat_flutter_ai/lib/src/composer/composer_attachment_sheet.dart +++ b/packages/stream_chat_flutter_ai/lib/src/composer/composer_attachment_sheet.dart @@ -412,7 +412,7 @@ class _RecentPhotoTileState extends State<_RecentPhotoTile> { DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), - border: Border.all(color: theme.selectionColor, width: 3), + border: Border.all(color: theme.photoSelectionColor, width: 3), ), ), if (selected) @@ -422,7 +422,7 @@ class _RecentPhotoTileState extends State<_RecentPhotoTile> { child: Container( width: 20, height: 20, - decoration: BoxDecoration(color: theme.selectionColor, shape: BoxShape.circle), + decoration: BoxDecoration(color: theme.photoSelectionColor, shape: BoxShape.circle), child: Icon(Icons.check, size: 14, color: theme.actionButtonForegroundColor), ), ), diff --git a/packages/stream_chat_flutter_ai/lib/src/composer/resolved_composer_theme.dart b/packages/stream_chat_flutter_ai/lib/src/composer/resolved_composer_theme.dart index 20cb099..87897af 100644 --- a/packages/stream_chat_flutter_ai/lib/src/composer/resolved_composer_theme.dart +++ b/packages/stream_chat_flutter_ai/lib/src/composer/resolved_composer_theme.dart @@ -6,8 +6,8 @@ import 'package:stream_chat_flutter_ai/src/theme/theme_lerp.dart'; /// A [ComposerThemeData] with every field resolved. /// /// Internal. [ComposerThemeData]'s fields are nullable, but the composer -/// widgets need a concrete value for each, so resolving once at the top of -/// `build` keeps the fallback table in one place. +/// widgets need a concrete value for each. Each widget resolves its own copy in +/// `build`, which is cheap and keeps the fallback table in one place. @immutable @internal class ResolvedComposerTheme { @@ -23,8 +23,9 @@ class ResolvedComposerTheme { required this.actionButtonForegroundColor, required this.selectedOptionColor, required this.selectedOptionForegroundColor, - required this.selectionColor, + required this.photoSelectionColor, required this.attachmentPlaceholderColor, + required this.brokenAttachmentIconColor, }); /// Resolves the theme at [context]. @@ -33,27 +34,32 @@ class ResolvedComposerTheme { final colorScheme = Theme.of(context).colorScheme; final iconColor = theme.iconColor ?? colorScheme.onSurface; + final hintColor = colorScheme.onSurfaceVariant.withValues(alpha: 0.6); + final hintStyle = mergeTextStyle(TextStyle(color: hintColor), theme.hintStyle); return ResolvedComposerTheme._( fillColor: theme.fillColor ?? colorScheme.surfaceContainerHigh, borderColor: theme.borderColor ?? colorScheme.outlineVariant, - hintStyle: mergeTextStyle( - TextStyle(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6)), - theme.hintStyle, - ), + // An `inherit: false` override replaces the base outright, color and all. + hintStyle: hintStyle.color == null ? hintStyle.copyWith(color: hintColor) : hintStyle, iconColor: iconColor, // Derived from the *resolved* icon color, not the scheme's: a host that // sets only `iconColor` gets a matching disabled shade for free, rather - // than a dimmed `onSurface` that no longer relates to it. - disabledIconColor: theme.disabledIconColor ?? iconColor.withValues(alpha: 0.3), + // than a dimmed `onSurface` that no longer relates to it. Scaled rather + // than replaced, so a translucent `iconColor` never gets a disabled shade + // more opaque than itself. + disabledIconColor: theme.disabledIconColor ?? iconColor.withValues(alpha: iconColor.a * 0.3), sendButtonColor: theme.sendButtonColor ?? colorScheme.primary, stopButtonColor: theme.stopButtonColor ?? colorScheme.error, recordingButtonColor: theme.recordingButtonColor ?? colorScheme.error, actionButtonForegroundColor: theme.actionButtonForegroundColor ?? Colors.white, selectedOptionColor: theme.selectedOptionColor ?? colorScheme.primaryContainer, selectedOptionForegroundColor: theme.selectedOptionForegroundColor ?? colorScheme.onPrimaryContainer, - selectionColor: theme.selectionColor ?? colorScheme.primary, + photoSelectionColor: theme.photoSelectionColor ?? colorScheme.primary, attachmentPlaceholderColor: theme.attachmentPlaceholderColor ?? colorScheme.surface, + // Follows a host `iconColor`, but defaults to the quieter + // `onSurfaceVariant` the placeholder glyph has always used. + brokenAttachmentIconColor: theme.iconColor ?? colorScheme.onSurfaceVariant, ); } @@ -90,12 +96,16 @@ class ResolvedComposerTheme { /// See [ComposerThemeData.selectedOptionForegroundColor]. final Color selectedOptionForegroundColor; - /// See [ComposerThemeData.selectionColor]. - final Color selectionColor; + /// See [ComposerThemeData.photoSelectionColor]. + final Color photoSelectionColor; /// See [ComposerThemeData.attachmentPlaceholderColor]. final Color attachmentPlaceholderColor; + /// The glyph on an attachment thumbnail that failed to decode. See + /// [ComposerThemeData.iconColor]. + final Color brokenAttachmentIconColor; + /// The icon color for a control in the given enabled state. Color iconColorFor({required bool enabled}) => enabled ? iconColor : disabledIconColor; } diff --git a/packages/stream_chat_flutter_ai/lib/src/theme/components/chart_theme.dart b/packages/stream_chat_flutter_ai/lib/src/theme/components/chart_theme.dart index 5abb21e..53978ee 100644 --- a/packages/stream_chat_flutter_ai/lib/src/theme/components/chart_theme.dart +++ b/packages/stream_chat_flutter_ai/lib/src/theme/components/chart_theme.dart @@ -46,7 +46,7 @@ const kDefaultChartSeriesColors = [ /// `reservedSize` values [ChartView] hands `fl_chart`. So a taller [height] /// leaves the pie undersized. @immutable -class ChartThemeData { +class ChartThemeData with Diagnosticable { /// Creates a [ChartThemeData]. const ChartThemeData({ this.seriesColors, @@ -208,6 +208,25 @@ class ChartThemeData { ); } + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(IterableProperty('seriesColors', seriesColors, defaultValue: null)) + ..add(DoubleProperty('height', height, defaultValue: null)) + ..add(DoubleProperty('scatterRadius', scatterRadius, defaultValue: null)) + ..add(DoubleProperty('bubbleMinRadius', bubbleMinRadius, defaultValue: null)) + ..add(DoubleProperty('bubbleMaxRadius', bubbleMaxRadius, defaultValue: null)) + ..add(IntProperty('histogramBinCount', histogramBinCount, defaultValue: null)) + ..add(DiagnosticsProperty('axisLabelStyle', axisLabelStyle, defaultValue: null)) + ..add(DiagnosticsProperty('pieLabelStyle', pieLabelStyle, defaultValue: null)) + ..add(DiagnosticsProperty('titleTextStyle', titleTextStyle, defaultValue: null)) + ..add(ColorProperty('gridLineColor', gridLineColor, defaultValue: null)) + ..add(ColorProperty('heatmapLowColor', heatmapLowColor, defaultValue: null)) + ..add(ColorProperty('heatmapMidColor', heatmapMidColor, defaultValue: null)) + ..add(ColorProperty('heatmapHighColor', heatmapHighColor, defaultValue: null)); + } + @override bool operator ==(Object other) { if (identical(this, other)) return true; diff --git a/packages/stream_chat_flutter_ai/lib/src/theme/components/composer_theme.dart b/packages/stream_chat_flutter_ai/lib/src/theme/components/composer_theme.dart index 619a265..8c4b9c1 100644 --- a/packages/stream_chat_flutter_ai/lib/src/theme/components/composer_theme.dart +++ b/packages/stream_chat_flutter_ai/lib/src/theme/components/composer_theme.dart @@ -31,7 +31,7 @@ import 'package:stream_chat_flutter_ai/src/theme/theme_lerp.dart'; /// because the composer's states have to keep occupying the same footprint as /// they morph between mic, send and stop. @immutable -class ComposerThemeData { +class ComposerThemeData with Diagnosticable { /// Creates a [ComposerThemeData]. const ComposerThemeData({ this.fillColor, @@ -45,13 +45,14 @@ class ComposerThemeData { this.actionButtonForegroundColor, this.selectedOptionColor, this.selectedOptionForegroundColor, - this.selectionColor, + this.photoSelectionColor, this.attachmentPlaceholderColor, }); /// The fill behind the input pill, the leading "+" button and the attachment - /// sheet's tiles — the surfaces that read as one connected control. Falls - /// back to [ColorScheme.surfaceContainerHigh]. + /// sheet's tiles — the surfaces that read as one connected control — and + /// behind a sheet photo tile while its thumbnail loads. Falls back to + /// [ColorScheme.surfaceContainerHigh]. final Color? fillColor; /// The hairline around the input pill, the leading button, the attachment @@ -59,8 +60,9 @@ class ComposerThemeData { /// [ColorScheme.outlineVariant]. final Color? borderColor; - /// The style of the placeholder in the text field. Falls back to - /// [ColorScheme.onSurfaceVariant] at 60% opacity. + /// The style of the placeholder in the text field, merged over a default of + /// [ColorScheme.onSurfaceVariant] at 60% opacity: a style that sets only a + /// `fontSize` keeps the default color, and so does one with no color at all. /// /// This is the placeholder's *appearance*; its text comes from /// [ChatComposer.hintText] or `AITranslations.composerHint`. @@ -70,13 +72,17 @@ class ComposerThemeData { /// tile and option icons, and an attachment thumbnail's remove "×". Falls /// back to [ColorScheme.onSurface]. /// + /// Also the glyph on an attachment thumbnail that failed to decode, which + /// falls back to the quieter [ColorScheme.onSurfaceVariant] instead. + /// /// Not the icon inside the filled send/stop/mic circle — that is /// [actionButtonForegroundColor], which sits on a saturated fill instead. final Color? iconColor; /// The color of those same icons while their control is disabled — the - /// leading button and the sheet's tiles while the AI is generating or the - /// composer is full. Falls back to [iconColor] at 30% opacity. + /// leading button while the AI is generating, and the sheet's camera tile + /// once the composer is full. Falls back to [iconColor] with its opacity + /// scaled to 30%. final Color? disabledIconColor; /// The fill of the send button. Falls back to [ColorScheme.primary]. @@ -96,8 +102,9 @@ class ComposerThemeData { /// button's slot, and the two read as one control morphing in place. final Color? recordingButtonColor; - /// The color of the icon inside the filled send/stop/mic circle. Falls back - /// to white, which is what the three default fills are legible against. + /// The color of the icon inside the filled send/stop/mic circle, and of the + /// check on a selected photo's [photoSelectionColor] badge. Falls back to + /// white, which is what the default fills are legible against. /// /// Set this alongside a pale [sendButtonColor]; white on pale is the one /// combination the defaults cannot anticipate. @@ -111,9 +118,11 @@ class ComposerThemeData { /// [ColorScheme.onPrimaryContainer]. final Color? selectedOptionForegroundColor; - /// The ring and check badge marking a selected photo in the attachment - /// sheet's grid. Falls back to [ColorScheme.primary]. - final Color? selectionColor; + /// The ring and the check badge's fill marking a selected photo in the + /// attachment sheet's grid. Falls back to [ColorScheme.primary]. + /// + /// The check itself is [actionButtonForegroundColor]. + final Color? photoSelectionColor; /// The placeholder behind an attachment thumbnail that is still decoding or /// failed to, and the fill of its remove badge. Falls back to @@ -134,7 +143,7 @@ class ComposerThemeData { Color? actionButtonForegroundColor, Color? selectedOptionColor, Color? selectedOptionForegroundColor, - Color? selectionColor, + Color? photoSelectionColor, Color? attachmentPlaceholderColor, }) { return ComposerThemeData( @@ -149,7 +158,7 @@ class ComposerThemeData { actionButtonForegroundColor: actionButtonForegroundColor ?? this.actionButtonForegroundColor, selectedOptionColor: selectedOptionColor ?? this.selectedOptionColor, selectedOptionForegroundColor: selectedOptionForegroundColor ?? this.selectedOptionForegroundColor, - selectionColor: selectionColor ?? this.selectionColor, + photoSelectionColor: photoSelectionColor ?? this.photoSelectionColor, attachmentPlaceholderColor: attachmentPlaceholderColor ?? this.attachmentPlaceholderColor, ); } @@ -170,7 +179,7 @@ class ComposerThemeData { actionButtonForegroundColor: other.actionButtonForegroundColor, selectedOptionColor: other.selectedOptionColor, selectedOptionForegroundColor: other.selectedOptionForegroundColor, - selectionColor: other.selectionColor, + photoSelectionColor: other.photoSelectionColor, attachmentPlaceholderColor: other.attachmentPlaceholderColor, ); } @@ -195,11 +204,30 @@ class ComposerThemeData { b.selectedOptionForegroundColor, t, ), - selectionColor: lerpColorOrSwap(a.selectionColor, b.selectionColor, t), + photoSelectionColor: lerpColorOrSwap(a.photoSelectionColor, b.photoSelectionColor, t), attachmentPlaceholderColor: lerpColorOrSwap(a.attachmentPlaceholderColor, b.attachmentPlaceholderColor, t), ); } + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ColorProperty('fillColor', fillColor, defaultValue: null)) + ..add(ColorProperty('borderColor', borderColor, defaultValue: null)) + ..add(DiagnosticsProperty('hintStyle', hintStyle, defaultValue: null)) + ..add(ColorProperty('iconColor', iconColor, defaultValue: null)) + ..add(ColorProperty('disabledIconColor', disabledIconColor, defaultValue: null)) + ..add(ColorProperty('sendButtonColor', sendButtonColor, defaultValue: null)) + ..add(ColorProperty('stopButtonColor', stopButtonColor, defaultValue: null)) + ..add(ColorProperty('recordingButtonColor', recordingButtonColor, defaultValue: null)) + ..add(ColorProperty('actionButtonForegroundColor', actionButtonForegroundColor, defaultValue: null)) + ..add(ColorProperty('selectedOptionColor', selectedOptionColor, defaultValue: null)) + ..add(ColorProperty('selectedOptionForegroundColor', selectedOptionForegroundColor, defaultValue: null)) + ..add(ColorProperty('photoSelectionColor', photoSelectionColor, defaultValue: null)) + ..add(ColorProperty('attachmentPlaceholderColor', attachmentPlaceholderColor, defaultValue: null)); + } + @override bool operator ==(Object other) { if (identical(this, other)) return true; @@ -215,7 +243,7 @@ class ComposerThemeData { other.actionButtonForegroundColor == actionButtonForegroundColor && other.selectedOptionColor == selectedOptionColor && other.selectedOptionForegroundColor == selectedOptionForegroundColor && - other.selectionColor == selectionColor && + other.photoSelectionColor == photoSelectionColor && other.attachmentPlaceholderColor == attachmentPlaceholderColor; } @@ -232,7 +260,7 @@ class ComposerThemeData { actionButtonForegroundColor, selectedOptionColor, selectedOptionForegroundColor, - selectionColor, + photoSelectionColor, attachmentPlaceholderColor, ); } diff --git a/packages/stream_chat_flutter_ai/lib/src/theme/components/suggestions_theme.dart b/packages/stream_chat_flutter_ai/lib/src/theme/components/suggestions_theme.dart index b575449..65d9d5f 100644 --- a/packages/stream_chat_flutter_ai/lib/src/theme/components/suggestions_theme.dart +++ b/packages/stream_chat_flutter_ai/lib/src/theme/components/suggestions_theme.dart @@ -30,7 +30,7 @@ import 'package:stream_chat_flutter_ai/src/theme/theme_lerp.dart'; /// Narrow it to a subtree with [SuggestionsTheme]. [copyWith] cannot unset a /// field — a `null` argument keeps the current value. @immutable -class SuggestionsThemeData { +class SuggestionsThemeData with Diagnosticable { /// Creates a [SuggestionsThemeData]. const SuggestionsThemeData({this.backgroundColor, this.borderColor, this.textStyle}); @@ -42,8 +42,9 @@ class SuggestionsThemeData { /// matching [ComposerThemeData.borderColor]'s default. final Color? borderColor; - /// The style of a chip's text. Falls back to the ambient [DefaultTextStyle] - /// in [ColorScheme.onSurface]. + /// The style of a chip's text, merged over the ambient [DefaultTextStyle] in + /// [ColorScheme.onSurface]: a style that sets only a weight keeps the app's + /// font, size and that color. /// /// Chips are measured before they are laid out — each one shrinks to fit its /// own two lines — so this style is what that measurement uses. A style that @@ -83,6 +84,15 @@ class SuggestionsThemeData { ); } + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)) + ..add(ColorProperty('borderColor', borderColor, defaultValue: null)) + ..add(DiagnosticsProperty('textStyle', textStyle, defaultValue: null)); + } + @override bool operator ==(Object other) { if (identical(this, other)) return true; diff --git a/packages/stream_chat_flutter_ai/lib/src/theme/theme_lerp.dart b/packages/stream_chat_flutter_ai/lib/src/theme/theme_lerp.dart index 6c60bed..e0440e1 100644 --- a/packages/stream_chat_flutter_ai/lib/src/theme/theme_lerp.dart +++ b/packages/stream_chat_flutter_ai/lib/src/theme/theme_lerp.dart @@ -37,12 +37,10 @@ Color? lerpColorOrSwap(Color? a, Color? b, double t) => bothSet(a, b) ? Color.le TextStyle? lerpTextStyleOrSwap(TextStyle? a, TextStyle? b, double t) => bothSet(a, b) ? TextStyle.lerp(a, b, t) : swapAtMidpoint(a, b, t); -/// Lays [override] over [base]. +/// Lays [override] over [base]; a `null` override leaves [base] as it is. /// -/// [TextStyle.merge] asserts on an `inherit: false` style, which a host is free -/// to build, so such a style replaces the base outright. +/// As with [TextStyle.merge], an `inherit: false` override replaces [base] +/// outright — so a caller that needs a property to survive, such as a color, +/// has to restore it afterwards. @internal -TextStyle mergeTextStyle(TextStyle base, TextStyle? override) { - if (override == null) return base; - return override.inherit ? base.merge(override) : override; -} +TextStyle mergeTextStyle(TextStyle base, TextStyle? override) => base.merge(override); diff --git a/packages/stream_chat_flutter_ai/test/src/composer/speech_to_text_test.dart b/packages/stream_chat_flutter_ai/test/src/composer/speech_to_text_test.dart index 77ef518..8ec6321 100644 --- a/packages/stream_chat_flutter_ai/test/src/composer/speech_to_text_test.dart +++ b/packages/stream_chat_flutter_ai/test/src/composer/speech_to_text_test.dart @@ -583,6 +583,46 @@ void main() { }); }); + group('SpeechToTextButton theming', () { + Future settle(WidgetTester tester) => tester.pump(const Duration(milliseconds: 200)); + + Color? micFill(WidgetTester tester) { + final box = tester.widget( + find.ancestor(of: find.byType(InkWell), matching: find.byType(DecoratedBox)).first, + ); + return (box.decoration as BoxDecoration).color; + } + + testWidgets('idles in sendButtonColor and listens in recordingButtonColor', (tester) async { + const send = Color(0xFF123456); + const recording = Color(0xFF654321); + final controller = ChatComposerController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _wrap( + ComposerTheme( + data: const ComposerThemeData(sendButtonColor: send, recordingButtonColor: recording), + child: SpeechToTextButton(controller: controller), + ), + ), + ); + + // Idle, the mic takes the send button's slot and its color, so the two + // read as one control morphing in place. + expect(micFill(tester), send); + + await tester.tap(find.byIcon(Icons.mic_none_rounded)); + await settle(tester); + + expect(find.byIcon(Icons.stop_rounded), findsOneWidget); + expect(micFill(tester), recording); + + await SpeechToTextController.instance.stop(); + await tester.pump(const Duration(seconds: 3)); + }); + }); + group('SpeechToTextButton localization', () { Future settle(WidgetTester tester) => tester.pump(const Duration(milliseconds: 200)); diff --git a/packages/stream_chat_flutter_ai/test/src/theme/ai_theme_test.dart b/packages/stream_chat_flutter_ai/test/src/theme/ai_theme_test.dart index 83b29b3..0ec51cf 100644 --- a/packages/stream_chat_flutter_ai/test/src/theme/ai_theme_test.dart +++ b/packages/stream_chat_flutter_ai/test/src/theme/ai_theme_test.dart @@ -72,5 +72,60 @@ void main() { expect(a.hashCode, b.hashCode); expect(a, isNot(const AITheme())); }); + group('composer and suggestions themes', () { + // Each case is one component field on AITheme. A field left out of `==` + // would let Theme.updateShouldNotify skip the rebuild after a host changed + // only that component; one left out of `lerp` would drop it for the whole + // light/dark animation MaterialApp runs. + const composer = ComposerThemeData(sendButtonColor: Color(0xFF0000AA)); + const composer2 = ComposerThemeData(sendButtonColor: Color(0xFF0000CC)); + const suggestions = SuggestionsThemeData(backgroundColor: Color(0xFF00AA00)); + const suggestions2 = SuggestionsThemeData(backgroundColor: Color(0xFF00CC00)); + const chart = ChartThemeData(height: 300); + + test('default to empty data', () { + expect(const AITheme().composerTheme, const ComposerThemeData()); + expect(const AITheme().suggestionsTheme, const SuggestionsThemeData()); + }); + + test('take part in equality and hashCode', () { + const withComposer = AITheme(composerTheme: composer); + const withSuggestions = AITheme(suggestionsTheme: suggestions); + + expect(withComposer, isNot(const AITheme())); + expect(withComposer.hashCode, isNot(const AITheme().hashCode)); + expect(withSuggestions, isNot(const AITheme())); + expect(withSuggestions.hashCode, isNot(const AITheme().hashCode)); + expect(withComposer, const AITheme(composerTheme: composer)); + expect(withSuggestions, const AITheme(suggestionsTheme: suggestions)); + }); + + test('copyWith replaces one component and keeps the others', () { + const theme = AITheme(chartTheme: chart, composerTheme: composer, suggestionsTheme: suggestions); + + final composerOnly = theme.copyWith(composerTheme: composer2); + expect(composerOnly.composerTheme, composer2); + expect(composerOnly.suggestionsTheme, suggestions); + expect(composerOnly.chartTheme, chart); + + final suggestionsOnly = theme.copyWith(suggestionsTheme: suggestions2); + expect(suggestionsOnly.suggestionsTheme, suggestions2); + expect(suggestionsOnly.composerTheme, composer); + expect(suggestionsOnly.chartTheme, chart); + + expect(theme.copyWith(), theme); + }); + + test('lerp delegates to each component', () { + const a = AITheme(composerTheme: composer, suggestionsTheme: suggestions); + const b = AITheme(composerTheme: composer2, suggestionsTheme: suggestions2); + + final mid = a.lerp(b, 0.5); + expect(mid.composerTheme, ComposerThemeData.lerp(composer, composer2, 0.5)); + expect(mid.suggestionsTheme, SuggestionsThemeData.lerp(suggestions, suggestions2, 0.5)); + expect(a.lerp(b, 0), a); + expect(a.lerp(b, 1), b); + }); + }); }); } diff --git a/packages/stream_chat_flutter_ai/test/src/theme/composer_theme_test.dart b/packages/stream_chat_flutter_ai/test/src/theme/composer_theme_test.dart index d0990fc..c256d31 100644 --- a/packages/stream_chat_flutter_ai/test/src/theme/composer_theme_test.dart +++ b/packages/stream_chat_flutter_ai/test/src/theme/composer_theme_test.dart @@ -4,10 +4,16 @@ import 'package:stream_chat_flutter_ai/stream_chat_flutter_ai.dart'; void main() { group('ComposerThemeData', () { - // Every field, rather than a spot-check: copyWith, merge, == and hashCode - // are four hand-written 13-line tables, and a crossed line in any of them - // is invisible until someone themes that particular field. The loop below - // is what makes a 14th field added to only three of the four fail loudly. + test('describes only the fields it sets to DevTools', () { + final description = const ComposerThemeData(fillColor: Color(0xFF123456)).toDiagnosticsNode().toStringDeep(); + expect(description, contains('fillColor')); + expect(description, isNot(contains('borderColor')), reason: 'unset fields stay out of the tree'); + }); + + // Every field, rather than a spot-check: copyWith, merge, lerp, == and + // hashCode are five hand-written 13-line tables, and a crossed line in any + // of them is invisible until someone themes that particular field. The loop + // below is what makes a 14th field added to only some of them fail loudly. group('every field', () { const base = ComposerThemeData( fillColor: Color(0xFF111111), @@ -21,7 +27,7 @@ void main() { actionButtonForegroundColor: Color(0xFF888888), selectedOptionColor: Color(0xFF999999), selectedOptionForegroundColor: Color(0xFFAAAAAA), - selectionColor: Color(0xFFBBBBBB), + photoSelectionColor: Color(0xFFBBBBBB), attachmentPlaceholderColor: Color(0xFFCCCCCC), ); @@ -102,10 +108,10 @@ void main() { read: (t) => t.selectedOptionForegroundColor, ), ( - name: 'selectionColor', - only: const ComposerThemeData(selectionColor: Color(0xFF0000AB)), - copied: () => base.copyWith(selectionColor: const Color(0xFF0000AB)), - read: (t) => t.selectionColor, + name: 'photoSelectionColor', + only: const ComposerThemeData(photoSelectionColor: Color(0xFF0000AB)), + copied: () => base.copyWith(photoSelectionColor: const Color(0xFF0000AB)), + read: (t) => t.photoSelectionColor, ), ( name: 'attachmentPlaceholderColor', @@ -140,6 +146,21 @@ void main() { expect(copied, isNot(base), reason: '== ignores ${field.name}'); expect(copied.hashCode, isNot(base.hashCode), reason: 'hashCode ignores ${field.name}'); }); + + test('${field.name} survives lerp', () { + // Only this field is set on both sides, so it alone interpolates; + // every other field swaps from base's value to null at the midpoint. + final early = ComposerThemeData.lerp(base, field.only, 0.25); + final end = ComposerThemeData.lerp(base, field.only, 1); + + expect(field.read(early), isNotNull, reason: 'lerp dropped ${field.name}'); + expect(field.read(early), isNot(field.read(base)), reason: 'lerp did not move ${field.name}'); + expect(field.read(end), field.read(field.only), reason: 'lerp did not reach ${field.name}'); + for (final other in fields.where((f) => f.name != field.name)) { + expect(other.read(early), other.read(base), reason: 'lerp(${field.name}) disturbed ${other.name}'); + expect(other.read(end), isNull, reason: 'lerp(${field.name}) disturbed ${other.name}'); + } + }); } }); diff --git a/packages/stream_chat_flutter_ai/test/src/theme/suggestions_theme_test.dart b/packages/stream_chat_flutter_ai/test/src/theme/suggestions_theme_test.dart index 11bbccc..edf92b7 100644 --- a/packages/stream_chat_flutter_ai/test/src/theme/suggestions_theme_test.dart +++ b/packages/stream_chat_flutter_ai/test/src/theme/suggestions_theme_test.dart @@ -4,6 +4,14 @@ import 'package:stream_chat_flutter_ai/stream_chat_flutter_ai.dart'; void main() { group('SuggestionsThemeData', () { + test('describes only the fields it sets to DevTools', () { + final description = const SuggestionsThemeData( + backgroundColor: Color(0xFF123456), + ).toDiagnosticsNode().toStringDeep(); + expect(description, contains('backgroundColor')); + expect(description, isNot(contains('borderColor')), reason: 'unset fields stay out of the tree'); + }); + group('every field', () { const base = SuggestionsThemeData( backgroundColor: Color(0xFF111111), @@ -65,6 +73,21 @@ void main() { expect(copied, isNot(base), reason: '== ignores ${field.name}'); expect(copied.hashCode, isNot(base.hashCode), reason: 'hashCode ignores ${field.name}'); }); + + test('${field.name} survives lerp', () { + // Only this field is set on both sides, so it alone interpolates; + // every other field swaps from base's value to null at the midpoint. + final early = SuggestionsThemeData.lerp(base, field.only, 0.25); + final end = SuggestionsThemeData.lerp(base, field.only, 1); + + expect(field.read(early), isNotNull, reason: 'lerp dropped ${field.name}'); + expect(field.read(early), isNot(field.read(base)), reason: 'lerp did not move ${field.name}'); + expect(field.read(end), field.read(field.only), reason: 'lerp did not reach ${field.name}'); + for (final other in fields.where((f) => f.name != field.name)) { + expect(other.read(early), other.read(base), reason: 'lerp(${field.name}) disturbed ${other.name}'); + expect(other.read(end), isNull, reason: 'lerp(${field.name}) disturbed ${other.name}'); + } + }); } }); diff --git a/packages/stream_chat_flutter_ai/test/src/theme/themed_components_test.dart b/packages/stream_chat_flutter_ai/test/src/theme/themed_components_test.dart index c95fd29..1dc4c93 100644 --- a/packages/stream_chat_flutter_ai/test/src/theme/themed_components_test.dart +++ b/packages/stream_chat_flutter_ai/test/src/theme/themed_components_test.dart @@ -31,8 +31,39 @@ Widget _app({required Widget child, AITheme? extension}) => MaterialApp( home: Scaffold(body: child), ); -/// The fill of the first [DecoratedBox] whose decoration is a [BoxDecoration] -/// carrying [color], searched under [scope]. +/// The input pill's own decoration: the rounded box directly around the +/// [TextField]. +/// +/// Targeted rather than "any box carrying this color", because the pill, the +/// leading "+" and the sheet's tiles share [ComposerThemeData.fillColor] on +/// purpose — a scan would pass on the "+" alone with the pill ignoring it. +BoxDecoration _pill(WidgetTester tester) { + final box = tester.widget( + find + .ancestor( + of: find.byType(TextField), + matching: find.byWidgetPredicate( + (w) => + w is DecoratedBox && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).borderRadius == BorderRadius.circular(24), + ), + ) + .first, + ); + return box.decoration as BoxDecoration; +} + +/// The leading "+" button's circle. +BoxDecoration _leadingButton(WidgetTester tester) { + final container = tester.widget( + find.ancestor(of: find.byIcon(Icons.add), matching: find.byType(Container)).first, + ); + return container.decoration! as BoxDecoration; +} + +Color? _borderColor(BoxDecoration decoration) => (decoration.border as Border?)?.top.color; + bool _hasBoxWithColor(WidgetTester tester, Finder scope, Color color) { final boxes = tester.widgetList( find.descendant(of: scope, matching: find.byType(DecoratedBox), matchRoot: true), @@ -43,18 +74,10 @@ bool _hasBoxWithColor(WidgetTester tester, Finder scope, Color color) { }); } -bool _hasContainerWithColor(WidgetTester tester, Color color) { - final containers = tester.widgetList(find.byType(Container)); - return containers.any((c) { - final decoration = c.decoration; - return decoration is BoxDecoration && decoration.color == color; - }); -} - -bool _hasAnimatedContainerWithColor(WidgetTester tester, Color color) { - // The send/stop/mic circle is an AnimatedContainer, whose *rendered* - // decoration lives on the state rather than the widget, so read it back - // through the element tree instead of the widget's own `decoration`. +bool _hasCircleWithColor(WidgetTester tester, Color color) { + // The send/stop/mic circle is an AnimatedContainer, which renders through a + // DecoratedBox carrying its current (settled) decoration — so scan those for + // a circle, rather than reading the widget's own target `decoration`. final found = tester.widgetList(find.byType(DecoratedBox)); return found.any((box) { final decoration = box.decoration; @@ -62,6 +85,22 @@ bool _hasAnimatedContainerWithColor(WidgetTester tester, Color color) { }); } +/// Opens the real attachment sheet from the "+" button. +Future _openSheet(WidgetTester tester) async { + await tester.tap(find.byIcon(Icons.add)); + // Not `pumpAndSettle` — the sheet's photo section spins a + // CircularProgressIndicator while its (unmocked) permission check is in + // flight, which never settles. See chat_composer_test.dart. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); +} + +/// Lets a thumbnail's failing `readAsBytes` land, so it shows its error glyph. +Future _failThumbnail(WidgetTester tester) async { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await tester.pump(); +} + void main() { group('ChatComposer honours ComposerThemeData', () { testWidgets('through the AITheme extension', (tester) async { @@ -75,8 +114,11 @@ void main() { ), ); - expect(_hasBoxWithColor(tester, find.byType(ChatComposer), _fill), isTrue, reason: 'the input pill takes fill'); - expect(_hasContainerWithColor(tester, _fill), isTrue, reason: 'the leading "+" button takes the same fill'); + expect(_pill(tester).color, _fill, reason: 'the input pill takes fillColor'); + expect(_borderColor(_pill(tester)), _border, reason: 'the input pill takes borderColor'); + expect(_leadingButton(tester).color, _fill, reason: 'the leading "+" takes the same fill'); + expect(_borderColor(_leadingButton(tester)), _border, reason: 'the leading "+" takes the same border'); + expect(tester.widget(find.byIcon(Icons.add)).color, _icon, reason: 'an enabled "+" takes iconColor'); }); testWidgets('through a ComposerTheme scope', (tester) async { @@ -92,7 +134,7 @@ void main() { ), ); - expect(_hasBoxWithColor(tester, find.byType(ChatComposer), _fill), isTrue); + expect(_pill(tester).color, _fill); }); testWidgets('sends in sendButtonColor and stops in stopButtonColor', (tester) async { @@ -110,14 +152,14 @@ void main() { ), ); - expect(_hasAnimatedContainerWithColor(tester, _send), isTrue, reason: 'send takes sendButtonColor'); + expect(_hasCircleWithColor(tester, _send), isTrue, reason: 'send takes sendButtonColor'); controller.isGenerating = true; await tester.pumpAndSettle(); - expect(_hasAnimatedContainerWithColor(tester, _stop), isTrue, reason: 'stop takes stopButtonColor'); + expect(_hasCircleWithColor(tester, _stop), isTrue, reason: 'stop takes stopButtonColor'); expect( - _hasAnimatedContainerWithColor(tester, _send), + _hasCircleWithColor(tester, _send), isFalse, reason: 'the send fill is gone once generating', ); @@ -180,22 +222,204 @@ void main() { expect(addIcon.color, _icon.withValues(alpha: 0.3)); }); + testWidgets('dims to a disabledIconColor that scales a translucent iconColor', (tester) async { + const translucent = Color(0x80112233); + final controller = ChatComposerController()..isGenerating = true; + addTearDown(controller.dispose); + + await tester.pumpWidget( + _app( + extension: const AITheme(composerTheme: ComposerThemeData(iconColor: translucent)), + child: ChatComposer(controller: controller, onSendPressed: (_, _, _) {}, onStopPressed: () {}), + ), + ); + + final addIcon = tester.widget(find.byIcon(Icons.add)); + // Scaled, not replaced: a disabled shade never more opaque than the + // enabled one. + expect(addIcon.color, translucent.withValues(alpha: translucent.a * 0.3)); + }); + + testWidgets('honours an explicit disabledIconColor', (tester) async { + const disabled = Color(0xFF0A0B0C); + final controller = ChatComposerController()..isGenerating = true; + addTearDown(controller.dispose); + + await tester.pumpWidget( + _app( + extension: const AITheme( + composerTheme: ComposerThemeData(iconColor: _icon, disabledIconColor: disabled), + ), + child: ChatComposer(controller: controller, onSendPressed: (_, _, _) {}, onStopPressed: () {}), + ), + ); + + expect(tester.widget(find.byIcon(Icons.add)).color, disabled); + }); + + group('hintStyle', () { + InputDecoration hint(WidgetTester tester) => tester.widget(find.byType(TextField)).decoration!; + final derived = ThemeData().colorScheme.onSurfaceVariant.withValues(alpha: 0.6); + + Future pump(WidgetTester tester, TextStyle style) async { + final controller = ChatComposerController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _app( + extension: AITheme(composerTheme: ComposerThemeData(hintStyle: style)), + child: ChatComposer(controller: controller, onSendPressed: (_, _, _) {}), + ), + ); + } + + testWidgets('merges over the derived color', (tester) async { + await pump(tester, const TextStyle(fontSize: 30)); + + expect(hint(tester).hintStyle?.fontSize, 30); + expect(hint(tester).hintStyle?.color, derived, reason: 'a size-only override keeps the default color'); + }); + + testWidgets('keeps a color through an inherit: false style', (tester) async { + // TextStyle.merge hands an `inherit: false` style back unmerged, which + // would leave the hint with no color at all. + await pump(tester, const TextStyle(inherit: false, fontSize: 30)); + + expect(hint(tester).hintStyle?.fontSize, 30); + expect(hint(tester).hintStyle?.color, derived); + }); + + testWidgets('takes an explicit color', (tester) async { + await pump(tester, const TextStyle(color: _icon)); + expect(hint(tester).hintStyle?.color, _icon); + }); + }); + + group('attachment thumbnails', () { + testWidgets('draw the placeholder, badge and glyphs from the theme', (tester) async { + const placeholder = Color(0xFF0C0D0E); + final controller = ChatComposerController()..addAttachments([XFile('does-not-exist.png')]); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _app( + extension: const AITheme( + composerTheme: ComposerThemeData( + iconColor: _icon, + borderColor: _border, + attachmentPlaceholderColor: placeholder, + ), + ), + child: ChatComposer(controller: controller, onSendPressed: (_, _, _) {}), + ), + ); + await _failThumbnail(tester); + // The unreadable file is reported, not swallowed; that report is not + // what this test is about. + expect(tester.takeException(), isA()); + + final broken = find.byIcon(Icons.broken_image_outlined); + expect(broken, findsOneWidget); + expect(tester.widget(broken).color, _icon, reason: 'a host iconColor reaches the error glyph'); + expect( + tester.widget(find.ancestor(of: broken, matching: find.byType(ColoredBox)).first).color, + placeholder, + ); + expect(tester.widget(find.byIcon(Icons.close)).color, _icon, reason: 'the remove "×" takes iconColor'); + }); + + testWidgets('keep the quieter default on the error glyph', (tester) async { + final controller = ChatComposerController()..addAttachments([XFile('does-not-exist.png')]); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _app( + child: ChatComposer(controller: controller, onSendPressed: (_, _, _) {}), + ), + ); + await _failThumbnail(tester); + expect(tester.takeException(), isA()); + + // onSurfaceVariant, as before theming — not iconColor's onSurface. + expect( + tester.widget(find.byIcon(Icons.broken_image_outlined)).color, + ThemeData().colorScheme.onSurfaceVariant, + ); + }); + }); + + group('attachment sheet', () { + testWidgets('takes the theme across the route it is pushed on', (tester) async { + const option = ChatOption(id: 'a', text: 'Deep research', icon: Icons.science); + final controller = ChatComposerController(chatOptions: [option]); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _app( + // A scope, not the extension: this is what has to cross the + // Navigator to reach the sheet. + child: ComposerTheme( + data: _composerTheme, + child: ChatComposer(controller: controller, onSendPressed: (_, _, _) {}), + ), + ), + ); + await _openSheet(tester); + + final sheet = find.byType(ComposerAttachmentSheet); + expect(sheet, findsOneWidget); + + final camera = find.byIcon(Icons.camera_alt_outlined); + expect(tester.widget(camera).color, _icon, reason: 'the camera tile takes iconColor'); + final cameraTile = tester.widget(find.ancestor(of: camera, matching: find.byType(Container)).first); + expect((cameraTile.decoration! as BoxDecoration).color, _fill, reason: 'the camera tile takes fillColor'); + + expect( + tester.widget(find.descendant(of: sheet, matching: find.byType(Divider))).color, + _border, + reason: 'the divider takes borderColor', + ); + expect(tester.widget(find.byIcon(Icons.science)).color, _icon, reason: 'option icons take iconColor'); + }); + + testWidgets('dims the camera tile once the composer is full', (tester) async { + final controller = ChatComposerController(maxAttachments: 1)..addAttachments([XFile('a.png')]); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _app( + child: ComposerTheme( + data: _composerTheme, + child: ChatComposer(controller: controller, onSendPressed: (_, _, _) {}), + ), + ), + ); + await _openSheet(tester); + + expect(tester.widget(find.byIcon(Icons.camera_alt_outlined)).color, _icon.withValues(alpha: 0.3)); + }); + }); + testWidgets('leaves the appearance alone when nothing is registered', (tester) async { - final controller = ChatComposerController(); + final controller = ChatComposerController(initialText: 'hi'); addTearDown(controller.dispose); await tester.pumpWidget( _app( - child: ChatComposer(controller: controller, onSendPressed: (_, _, _) {}), + child: ChatComposer(controller: controller, onSendPressed: (_, _, _) {}, onStopPressed: () {}), ), ); final scheme = ThemeData().colorScheme; - expect( - _hasBoxWithColor(tester, find.byType(ChatComposer), scheme.surfaceContainerHigh), - isTrue, - reason: 'the untouched default is still ColorScheme.surfaceContainerHigh', - ); + expect(_pill(tester).color, scheme.surfaceContainerHigh); + expect(_borderColor(_pill(tester)), scheme.outlineVariant); + expect(_leadingButton(tester).color, scheme.surfaceContainerHigh); + expect(tester.widget(find.byIcon(Icons.add)).color, scheme.onSurface); + expect(_hasCircleWithColor(tester, scheme.primary), isTrue, reason: 'send falls back to primary'); + expect(tester.widget(find.byIcon(Icons.arrow_upward_rounded)).color, Colors.white); + + controller.isGenerating = true; + await tester.pumpAndSettle(); + expect(_hasCircleWithColor(tester, scheme.error), isTrue, reason: 'stop falls back to error'); }); }); @@ -229,7 +453,31 @@ void main() { final text = tester.widget(find.text('Tell me a joke')); expect(text.style?.fontWeight, FontWeight.bold, reason: 'the theme weight applies'); - expect(text.style?.color, isNotNull, reason: 'a partial override keeps the derived color'); + expect( + text.style?.color, + ThemeData().colorScheme.onSurface, + reason: 'a partial override keeps the derived color', + ); + }); + + testWidgets('resizes the chips to a larger textStyle rather than overflowing', (tester) async { + Future chipWidth(TextStyle? style) async { + await tester.pumpWidget( + _app( + extension: AITheme(suggestionsTheme: SuggestionsThemeData(textStyle: style)), + child: AISuggestionsView(suggestions: const ['Hi'], onSuggestionSelected: (_) {}), + ), + ); + // MaterialApp animates between themes; let it land on the new one. + await tester.pumpAndSettle(); + return tester.getSize(find.ancestor(of: find.text('Hi'), matching: find.byType(InkWell)).first).width; + } + + final normal = await chipWidth(null); + final large = await chipWidth(const TextStyle(fontSize: 30)); + + expect(tester.takeException(), isNull, reason: 'no overflow'); + expect(large, greaterThan(normal)); }); testWidgets('leaves the appearance alone when nothing is registered', (tester) async { From e043a33aff10eb21e9b6859bf103e1d79917feb2 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 23 Sep 2026 14:26:29 +0200 Subject: [PATCH 4/4] fix(ai): pair each composer glyph with the surface it sits on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - iconColor now covers only the icons drawn on fillColor (the "+" and the camera tile). A thumbnail's remove "×" and broken-image glyph take a new attachmentPlaceholderForegroundColor, paired with the placeholder they sit on; the sheet's chat-option icons follow ColorScheme.onSurface, as the sheet background under them is unthemed. Defaults match main. - ComposerActionButton takes an optional foregroundColor alongside color, falling back to the theme's actionButtonForegroundColor. - AISuggestionsView restores onSurface when an inherit: false textStyle would leave the chip text with no color, as the composer does for its hint. Co-Authored-By: Claude Opus 5.5 --- packages/stream_chat_flutter_ai/CHANGELOG.md | 3 +- packages/stream_chat_flutter_ai/README.md | 6 +- .../lib/src/composer/chat_composer_input.dart | 2 +- .../src/composer/composer_action_button.dart | 10 ++- .../composer/composer_attachment_sheet.dart | 2 +- .../src/composer/resolved_composer_theme.dart | 22 ++++-- .../lib/src/composer/suggestions_view.dart | 5 +- .../src/theme/components/composer_theme.dart | 38 ++++++++-- .../theme/components/suggestions_theme.dart | 3 +- .../test/src/theme/composer_theme_test.dart | 13 +++- .../src/theme/themed_components_test.dart | 69 +++++++++++++++++-- 11 files changed, 145 insertions(+), 28 deletions(-) diff --git a/packages/stream_chat_flutter_ai/CHANGELOG.md b/packages/stream_chat_flutter_ai/CHANGELOG.md index 8a6d252..5ab40b1 100644 --- a/packages/stream_chat_flutter_ai/CHANGELOG.md +++ b/packages/stream_chat_flutter_ai/CHANGELOG.md @@ -7,7 +7,8 @@ color from the ambient `ColorScheme` or hard-coded white, with no seam for a host to intervene; they now take the pill's fill and border, the hint style, icon and disabled-icon colors, the send, stop and recording button fills and the glyph on them, the selected-option chip, the photo grid's - selection ring, the attachment placeholder, and the chips' fill, border and text style. Narrow either to a + selection ring, the attachment placeholder and the glyphs on it, and the chips' fill, border and + text style. Narrow either to a subtree with `ComposerTheme` / `SuggestionsTheme`, which layer over the extension and cross a `Navigator` the way `ChartTheme` does — so a scope above the composer reaches its attachment sheet. diff --git a/packages/stream_chat_flutter_ai/README.md b/packages/stream_chat_flutter_ai/README.md index 5447720..780f926 100644 --- a/packages/stream_chat_flutter_ai/README.md +++ b/packages/stream_chat_flutter_ai/README.md @@ -599,8 +599,10 @@ the appearance the package has always had; theming is opt-in. as one control), `hintStyle`, `iconColor` and `disabledIconColor`, `sendButtonColor`, `stopButtonColor`, `recordingButtonColor`, `actionButtonForegroundColor` (the glyph on that filled circle), `selectedOptionColor` and `selectedOptionForegroundColor`, `photoSelectionColor` (the photo -grid's ring and check) and `attachmentPlaceholderColor`. `SuggestionsThemeData` has three: -`backgroundColor`, `borderColor` and `textStyle`. +grid's ring and check), and `attachmentPlaceholderColor` with `attachmentPlaceholderForegroundColor` +(a thumbnail's placeholder and remove badge, and the glyphs on them). Each foreground field is paired +with the surface it is drawn on, so overriding one pair never leaves an icon on a surface it wasn't +chosen for. `SuggestionsThemeData` has three: `backgroundColor`, `borderColor` and `textStyle`. Narrow either to a subtree with `ComposerTheme` / `SuggestionsTheme`, the way `ChartTheme` does: diff --git a/packages/stream_chat_flutter_ai/lib/src/composer/chat_composer_input.dart b/packages/stream_chat_flutter_ai/lib/src/composer/chat_composer_input.dart index a54f2fb..21f6bae 100644 --- a/packages/stream_chat_flutter_ai/lib/src/composer/chat_composer_input.dart +++ b/packages/stream_chat_flutter_ai/lib/src/composer/chat_composer_input.dart @@ -415,7 +415,7 @@ class _AttachmentThumbnailState extends State<_AttachmentThumbnail> { child: Icon( Icons.close, size: 14, - color: theme.iconColor, + color: theme.removeAttachmentIconColor, ), ), ), diff --git a/packages/stream_chat_flutter_ai/lib/src/composer/composer_action_button.dart b/packages/stream_chat_flutter_ai/lib/src/composer/composer_action_button.dart index 57530ef..5a2d70f 100644 --- a/packages/stream_chat_flutter_ai/lib/src/composer/composer_action_button.dart +++ b/packages/stream_chat_flutter_ai/lib/src/composer/composer_action_button.dart @@ -14,6 +14,7 @@ class ComposerActionButton extends StatelessWidget { required this.onPressed, required this.tooltip, required this.color, + this.foregroundColor, }); /// The icon shown inside the button. @@ -30,6 +31,13 @@ class ComposerActionButton extends StatelessWidget { /// Automatically dimmed when [onPressed] is `null`. final Color color; + /// The icon's color. Falls back to the theme's + /// `ComposerThemeData.actionButtonForegroundColor`. + /// + /// Set it alongside a [color] the theme's foreground was not chosen for — a + /// pale fill, say, under the default white. + final Color? foregroundColor; + /// The button's fixed diameter. /// /// Shared across every trailing-control state so mic/send/stop occupy @@ -39,7 +47,7 @@ class ComposerActionButton extends StatelessWidget { @override Widget build(BuildContext context) { final enabled = onPressed != null; - final foregroundColor = ResolvedComposerTheme.resolve(context).actionButtonForegroundColor; + final foregroundColor = this.foregroundColor ?? ResolvedComposerTheme.resolve(context).actionButtonForegroundColor; return Tooltip( message: tooltip, child: AnimatedContainer( diff --git a/packages/stream_chat_flutter_ai/lib/src/composer/composer_attachment_sheet.dart b/packages/stream_chat_flutter_ai/lib/src/composer/composer_attachment_sheet.dart index fd10da3..ee251f2 100644 --- a/packages/stream_chat_flutter_ai/lib/src/composer/composer_attachment_sheet.dart +++ b/packages/stream_chat_flutter_ai/lib/src/composer/composer_attachment_sheet.dart @@ -443,7 +443,7 @@ class _ChatOptionTile extends StatelessWidget { Widget build(BuildContext context) { final theme = ResolvedComposerTheme.resolve(context); return ListTile( - leading: option.icon != null ? Icon(option.icon, color: theme.iconColor) : null, + leading: option.icon != null ? Icon(option.icon, color: theme.chatOptionIconColor) : null, title: Text(option.text), subtitle: option.description != null ? Text(option.description!) : null, onTap: onTap, diff --git a/packages/stream_chat_flutter_ai/lib/src/composer/resolved_composer_theme.dart b/packages/stream_chat_flutter_ai/lib/src/composer/resolved_composer_theme.dart index 87897af..a368932 100644 --- a/packages/stream_chat_flutter_ai/lib/src/composer/resolved_composer_theme.dart +++ b/packages/stream_chat_flutter_ai/lib/src/composer/resolved_composer_theme.dart @@ -25,7 +25,9 @@ class ResolvedComposerTheme { required this.selectedOptionForegroundColor, required this.photoSelectionColor, required this.attachmentPlaceholderColor, + required this.removeAttachmentIconColor, required this.brokenAttachmentIconColor, + required this.chatOptionIconColor, }); /// Resolves the theme at [context]. @@ -57,9 +59,11 @@ class ResolvedComposerTheme { selectedOptionForegroundColor: theme.selectedOptionForegroundColor ?? colorScheme.onPrimaryContainer, photoSelectionColor: theme.photoSelectionColor ?? colorScheme.primary, attachmentPlaceholderColor: theme.attachmentPlaceholderColor ?? colorScheme.surface, - // Follows a host `iconColor`, but defaults to the quieter - // `onSurfaceVariant` the placeholder glyph has always used. - brokenAttachmentIconColor: theme.iconColor ?? colorScheme.onSurfaceVariant, + // One field for both glyphs on the placeholder, with the defaults each + // has always had: the broken-image icon is the quieter of the two. + removeAttachmentIconColor: theme.attachmentPlaceholderForegroundColor ?? colorScheme.onSurface, + brokenAttachmentIconColor: theme.attachmentPlaceholderForegroundColor ?? colorScheme.onSurfaceVariant, + chatOptionIconColor: colorScheme.onSurface, ); } @@ -102,10 +106,20 @@ class ResolvedComposerTheme { /// See [ComposerThemeData.attachmentPlaceholderColor]. final Color attachmentPlaceholderColor; + /// The remove badge's "×". See [ComposerThemeData.attachmentPlaceholderForegroundColor]. + final Color removeAttachmentIconColor; + /// The glyph on an attachment thumbnail that failed to decode. See - /// [ComposerThemeData.iconColor]. + /// [ComposerThemeData.attachmentPlaceholderForegroundColor]. final Color brokenAttachmentIconColor; + /// A chat option's icon in the attachment sheet. + /// + /// Not themed: it sits on the sheet's own background, which follows the + /// ambient [ColorScheme] rather than [ComposerThemeData], so the icon has to + /// follow the scheme too to stay legible against it. + final Color chatOptionIconColor; + /// The icon color for a control in the given enabled state. Color iconColorFor({required bool enabled}) => enabled ? iconColor : disabledIconColor; } diff --git a/packages/stream_chat_flutter_ai/lib/src/composer/suggestions_view.dart b/packages/stream_chat_flutter_ai/lib/src/composer/suggestions_view.dart index f24a1dc..59389cc 100644 --- a/packages/stream_chat_flutter_ai/lib/src/composer/suggestions_view.dart +++ b/packages/stream_chat_flutter_ai/lib/src/composer/suggestions_view.dart @@ -64,9 +64,12 @@ class AISuggestionsView extends StatelessWidget { // Merged onto the ambient DefaultTextStyle rather than used raw, so a theme // setting only a color or weight keeps the app's font and size. The chips // are measured with this exact style — see `_measureContentWidth`. - final textStyle = DefaultTextStyle.of( + final merged = DefaultTextStyle.of( context, ).style.merge(TextStyle(color: colorScheme.onSurface)).merge(theme.textStyle); + // An `inherit: false` override replaces the base outright, color and all — + // restore it, as the composer does for its hint. + final textStyle = merged.color == null ? merged.copyWith(color: colorScheme.onSurface) : merged; final textScaler = MediaQuery.textScalerOf(context); final textDirection = Directionality.of(context); diff --git a/packages/stream_chat_flutter_ai/lib/src/theme/components/composer_theme.dart b/packages/stream_chat_flutter_ai/lib/src/theme/components/composer_theme.dart index 8c4b9c1..564e5b6 100644 --- a/packages/stream_chat_flutter_ai/lib/src/theme/components/composer_theme.dart +++ b/packages/stream_chat_flutter_ai/lib/src/theme/components/composer_theme.dart @@ -47,6 +47,7 @@ class ComposerThemeData with Diagnosticable { this.selectedOptionForegroundColor, this.photoSelectionColor, this.attachmentPlaceholderColor, + this.attachmentPlaceholderForegroundColor, }); /// The fill behind the input pill, the leading "+" button and the attachment @@ -68,12 +69,14 @@ class ComposerThemeData with Diagnosticable { /// [ChatComposer.hintText] or `AITranslations.composerHint`. final TextStyle? hintStyle; - /// The color of the icons drawn on [fillColor]: the leading "+", the sheet's - /// tile and option icons, and an attachment thumbnail's remove "×". Falls - /// back to [ColorScheme.onSurface]. + /// The color of the icons drawn on [fillColor]: the leading "+" and the + /// sheet's camera tile. Falls back to [ColorScheme.onSurface]. /// - /// Also the glyph on an attachment thumbnail that failed to decode, which - /// falls back to the quieter [ColorScheme.onSurfaceVariant] instead. + /// Only those, so it need only contrast with [fillColor]. An attachment + /// thumbnail's glyphs sit on [attachmentPlaceholderColor] and take + /// [attachmentPlaceholderForegroundColor]; the sheet's chat-option icons sit + /// on the sheet's own background, which this theme does not set, and follow + /// [ColorScheme.onSurface]. /// /// Not the icon inside the filled send/stop/mic circle — that is /// [actionButtonForegroundColor], which sits on a saturated fill instead. @@ -129,6 +132,13 @@ class ComposerThemeData with Diagnosticable { /// [ColorScheme.surface]. final Color? attachmentPlaceholderColor; + /// The glyphs drawn on [attachmentPlaceholderColor]: the remove badge's "×" + /// and the icon on a thumbnail that failed to decode. + /// + /// Unset, the "×" falls back to [ColorScheme.onSurface] and the broken-image + /// icon to the quieter [ColorScheme.onSurfaceVariant]; set, both take it. + final Color? attachmentPlaceholderForegroundColor; + /// Returns a copy with the given fields replaced. A `null` argument keeps the /// current value. ComposerThemeData copyWith({ @@ -145,6 +155,7 @@ class ComposerThemeData with Diagnosticable { Color? selectedOptionForegroundColor, Color? photoSelectionColor, Color? attachmentPlaceholderColor, + Color? attachmentPlaceholderForegroundColor, }) { return ComposerThemeData( fillColor: fillColor ?? this.fillColor, @@ -160,6 +171,8 @@ class ComposerThemeData with Diagnosticable { selectedOptionForegroundColor: selectedOptionForegroundColor ?? this.selectedOptionForegroundColor, photoSelectionColor: photoSelectionColor ?? this.photoSelectionColor, attachmentPlaceholderColor: attachmentPlaceholderColor ?? this.attachmentPlaceholderColor, + attachmentPlaceholderForegroundColor: + attachmentPlaceholderForegroundColor ?? this.attachmentPlaceholderForegroundColor, ); } @@ -181,6 +194,7 @@ class ComposerThemeData with Diagnosticable { selectedOptionForegroundColor: other.selectedOptionForegroundColor, photoSelectionColor: other.photoSelectionColor, attachmentPlaceholderColor: other.attachmentPlaceholderColor, + attachmentPlaceholderForegroundColor: other.attachmentPlaceholderForegroundColor, ); } @@ -206,6 +220,11 @@ class ComposerThemeData with Diagnosticable { ), photoSelectionColor: lerpColorOrSwap(a.photoSelectionColor, b.photoSelectionColor, t), attachmentPlaceholderColor: lerpColorOrSwap(a.attachmentPlaceholderColor, b.attachmentPlaceholderColor, t), + attachmentPlaceholderForegroundColor: lerpColorOrSwap( + a.attachmentPlaceholderForegroundColor, + b.attachmentPlaceholderForegroundColor, + t, + ), ); } @@ -225,7 +244,10 @@ class ComposerThemeData with Diagnosticable { ..add(ColorProperty('selectedOptionColor', selectedOptionColor, defaultValue: null)) ..add(ColorProperty('selectedOptionForegroundColor', selectedOptionForegroundColor, defaultValue: null)) ..add(ColorProperty('photoSelectionColor', photoSelectionColor, defaultValue: null)) - ..add(ColorProperty('attachmentPlaceholderColor', attachmentPlaceholderColor, defaultValue: null)); + ..add(ColorProperty('attachmentPlaceholderColor', attachmentPlaceholderColor, defaultValue: null)) + ..add( + ColorProperty('attachmentPlaceholderForegroundColor', attachmentPlaceholderForegroundColor, defaultValue: null), + ); } @override @@ -244,7 +266,8 @@ class ComposerThemeData with Diagnosticable { other.selectedOptionColor == selectedOptionColor && other.selectedOptionForegroundColor == selectedOptionForegroundColor && other.photoSelectionColor == photoSelectionColor && - other.attachmentPlaceholderColor == attachmentPlaceholderColor; + other.attachmentPlaceholderColor == attachmentPlaceholderColor && + other.attachmentPlaceholderForegroundColor == attachmentPlaceholderForegroundColor; } @override @@ -262,6 +285,7 @@ class ComposerThemeData with Diagnosticable { selectedOptionForegroundColor, photoSelectionColor, attachmentPlaceholderColor, + attachmentPlaceholderForegroundColor, ); } diff --git a/packages/stream_chat_flutter_ai/lib/src/theme/components/suggestions_theme.dart b/packages/stream_chat_flutter_ai/lib/src/theme/components/suggestions_theme.dart index 65d9d5f..4595b2d 100644 --- a/packages/stream_chat_flutter_ai/lib/src/theme/components/suggestions_theme.dart +++ b/packages/stream_chat_flutter_ai/lib/src/theme/components/suggestions_theme.dart @@ -44,7 +44,8 @@ class SuggestionsThemeData with Diagnosticable { /// The style of a chip's text, merged over the ambient [DefaultTextStyle] in /// [ColorScheme.onSurface]: a style that sets only a weight keeps the app's - /// font, size and that color. + /// font, size and that color. An `inherit: false` style replaces the rest, + /// but still keeps that color unless it sets its own. /// /// Chips are measured before they are laid out — each one shrinks to fit its /// own two lines — so this style is what that measurement uses. A style that diff --git a/packages/stream_chat_flutter_ai/test/src/theme/composer_theme_test.dart b/packages/stream_chat_flutter_ai/test/src/theme/composer_theme_test.dart index c256d31..2be521b 100644 --- a/packages/stream_chat_flutter_ai/test/src/theme/composer_theme_test.dart +++ b/packages/stream_chat_flutter_ai/test/src/theme/composer_theme_test.dart @@ -11,7 +11,7 @@ void main() { }); // Every field, rather than a spot-check: copyWith, merge, lerp, == and - // hashCode are five hand-written 13-line tables, and a crossed line in any + // hashCode are five hand-written 14-line tables, and a crossed line in any // of them is invisible until someone themes that particular field. The loop // below is what makes a 14th field added to only some of them fail loudly. group('every field', () { @@ -29,6 +29,7 @@ void main() { selectedOptionForegroundColor: Color(0xFFAAAAAA), photoSelectionColor: Color(0xFFBBBBBB), attachmentPlaceholderColor: Color(0xFFCCCCCC), + attachmentPlaceholderForegroundColor: Color(0xFFDDDDDD), ); /// One per field: how to set it to a second value, and how to read it. @@ -119,13 +120,19 @@ void main() { copied: () => base.copyWith(attachmentPlaceholderColor: const Color(0xFF0000AC)), read: (t) => t.attachmentPlaceholderColor, ), + ( + name: 'attachmentPlaceholderForegroundColor', + only: const ComposerThemeData(attachmentPlaceholderForegroundColor: Color(0xFF0000AD)), + copied: () => base.copyWith(attachmentPlaceholderForegroundColor: const Color(0xFF0000AD)), + read: (t) => t.attachmentPlaceholderForegroundColor, + ), ]; test('the table covers every field the class declares', () { // Guards the loop itself: add a field without a row here and this // fails rather than silently leaving the new field untested. - expect(fields, hasLength(13)); - expect(fields.map((f) => f.name).toSet(), hasLength(13)); + expect(fields, hasLength(14)); + expect(fields.map((f) => f.name).toSet(), hasLength(14)); }); for (final field in fields) { diff --git a/packages/stream_chat_flutter_ai/test/src/theme/themed_components_test.dart b/packages/stream_chat_flutter_ai/test/src/theme/themed_components_test.dart index 1dc4c93..cf819ec 100644 --- a/packages/stream_chat_flutter_ai/test/src/theme/themed_components_test.dart +++ b/packages/stream_chat_flutter_ai/test/src/theme/themed_components_test.dart @@ -180,6 +180,35 @@ void main() { expect(sendIcon.color, _actionForeground); }); + testWidgets("a ComposerActionButton's own foregroundColor wins over the theme", (tester) async { + const own = Color(0xFF0F0F0F); + + await tester.pumpWidget( + _app( + extension: const AITheme(composerTheme: _composerTheme), + child: Column( + children: [ + ComposerActionButton( + icon: Icons.star, + onPressed: () {}, + tooltip: 'own', + color: _send, + foregroundColor: own, + ), + ComposerActionButton(icon: Icons.favorite, onPressed: () {}, tooltip: 'themed', color: _send), + ], + ), + ), + ); + + expect(tester.widget(find.byIcon(Icons.star)).color, own); + expect( + tester.widget(find.byIcon(Icons.favorite)).color, + _actionForeground, + reason: 'unset, it follows the theme', + ); + }); + testWidgets('paints the selected-option chip in its own colors', (tester) async { final controller = ChatComposerController() ..selectChatOption(const ChatOption(id: 'a', text: 'Summarize', icon: Icons.summarize)); @@ -297,6 +326,7 @@ void main() { group('attachment thumbnails', () { testWidgets('draw the placeholder, badge and glyphs from the theme', (tester) async { const placeholder = Color(0xFF0C0D0E); + const glyph = Color(0xFF0E0D0C); final controller = ChatComposerController()..addAttachments([XFile('does-not-exist.png')]); addTearDown(controller.dispose); @@ -307,6 +337,7 @@ void main() { iconColor: _icon, borderColor: _border, attachmentPlaceholderColor: placeholder, + attachmentPlaceholderForegroundColor: glyph, ), ), child: ChatComposer(controller: controller, onSendPressed: (_, _, _) {}), @@ -319,30 +350,37 @@ void main() { final broken = find.byIcon(Icons.broken_image_outlined); expect(broken, findsOneWidget); - expect(tester.widget(broken).color, _icon, reason: 'a host iconColor reaches the error glyph'); + expect(tester.widget(broken).color, glyph, reason: 'the error glyph takes the placeholder foreground'); expect( tester.widget(find.ancestor(of: broken, matching: find.byType(ColoredBox)).first).color, placeholder, ); - expect(tester.widget(find.byIcon(Icons.close)).color, _icon, reason: 'the remove "×" takes iconColor'); + expect(tester.widget(find.byIcon(Icons.close)).color, glyph, reason: 'so does the remove "×"'); }); - testWidgets('keep the quieter default on the error glyph', (tester) async { + testWidgets('keep their scheme defaults under a host iconColor', (tester) async { final controller = ChatComposerController()..addAttachments([XFile('does-not-exist.png')]); addTearDown(controller.dispose); + // A dark pill with white icons. The glyphs sit on the placeholder, not + // the pill, so white would vanish on its default `surface`. await tester.pumpWidget( _app( + extension: const AITheme( + composerTheme: ComposerThemeData(fillColor: Color(0xFF0A1A3A), iconColor: Colors.white), + ), child: ChatComposer(controller: controller, onSendPressed: (_, _, _) {}), ), ); await _failThumbnail(tester); expect(tester.takeException(), isA()); - // onSurfaceVariant, as before theming — not iconColor's onSurface. + final scheme = ThemeData().colorScheme; + expect(tester.widget(find.byIcon(Icons.close)).color, scheme.onSurface); + // The quieter onSurfaceVariant, as before theming. expect( tester.widget(find.byIcon(Icons.broken_image_outlined)).color, - ThemeData().colorScheme.onSurfaceVariant, + scheme.onSurfaceVariant, ); }); }); @@ -378,7 +416,11 @@ void main() { _border, reason: 'the divider takes borderColor', ); - expect(tester.widget(find.byIcon(Icons.science)).color, _icon, reason: 'option icons take iconColor'); + expect( + tester.widget(find.byIcon(Icons.science)).color, + ThemeData().colorScheme.onSurface, + reason: 'option icons sit on the unthemed sheet background, so they follow the scheme, not iconColor', + ); }); testWidgets('dims the camera tile once the composer is full', (tester) async { @@ -460,6 +502,21 @@ void main() { ); }); + testWidgets('keeps the derived color through an inherit: false textStyle', (tester) async { + await tester.pumpWidget( + _app( + extension: const AITheme( + suggestionsTheme: SuggestionsThemeData(textStyle: TextStyle(inherit: false, fontWeight: FontWeight.bold)), + ), + child: AISuggestionsView(suggestions: const ['Tell me a joke'], onSuggestionSelected: (_) {}), + ), + ); + + final text = tester.widget(find.text('Tell me a joke')); + expect(text.style?.fontWeight, FontWeight.bold); + expect(text.style?.color, ThemeData().colorScheme.onSurface); + }); + testWidgets('resizes the chips to a larger textStyle rather than overflowing', (tester) async { Future chipWidth(TextStyle? style) async { await tester.pumpWidget(