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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/stream_chat_flutter_ai/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
## Unreleased

✅ Added

- **`ComposerThemeData` and `SuggestionsThemeData`**, joining `ChartThemeData` on the `AITheme`
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 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.

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`.
Expand Down
64 changes: 64 additions & 0 deletions packages/stream_chat_flutter_ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,70 @@ 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`, `photoSelectionColor` (the photo
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:

```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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
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';
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));
Expand All @@ -15,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,
Expand All @@ -41,7 +44,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
Expand All @@ -55,7 +58,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,
Expand Down Expand Up @@ -108,13 +111,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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -106,18 +107,17 @@ class _ChatComposerInputState extends State<ChatComposerInput> {
}

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.
final attachments = controller.attachments;

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,
Expand Down Expand Up @@ -149,11 +149,7 @@ class _ChatComposerInputState extends State<ChatComposerInput> {
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),
),
Expand Down Expand Up @@ -228,15 +224,15 @@ 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) {
return ComposerActionButton(
icon: Icons.stop_rounded,
onPressed: onStop,
tooltip: translations.stopGenerating,
color: colorScheme.error,
color: theme.stopButtonColor,
);
}

Expand All @@ -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,
);
}
}
Expand Down Expand Up @@ -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();

Expand All @@ -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.brokenAttachmentIconColor,
),
);
}
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);
},
Expand All @@ -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
Expand All @@ -419,7 +415,7 @@ class _AttachmentThumbnailState extends State<_AttachmentThumbnail> {
child: Icon(
Icons.close,
size: 14,
color: colorScheme.onSurface,
color: theme.removeAttachmentIconColor,
),
),
),
Expand All @@ -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(
Expand All @@ -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,
),
),
Expand All @@ -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),
),
),
),
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
///
Expand All @@ -13,6 +14,7 @@ class ComposerActionButton extends StatelessWidget {
required this.onPressed,
required this.tooltip,
required this.color,
this.foregroundColor,
});

/// The icon shown inside the button.
Expand All @@ -29,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
Expand All @@ -38,6 +47,7 @@ class ComposerActionButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final enabled = onPressed != null;
final foregroundColor = this.foregroundColor ?? ResolvedComposerTheme.resolve(context).actionButtonForegroundColor;
return Tooltip(
message: tooltip,
child: AnimatedContainer(
Expand All @@ -57,7 +67,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)),
),
),
),
Expand Down
Loading
Loading