
AI components for the Stream Flutter Chat SDK
This official repository for Stream Chat's UI components is designed specifically for AI-first applications written in Flutter. When paired with our real-time Chat API, it makes integrating with and rendering responses from LLM providers such as ChatGPT, Gemini, Anthropic or any custom backend easier, by providing rich out-of-the-box components able to render Markdown, code blocks, tables, charts, thinking indicators and more.
The package has no dependency on stream_chat, stream_chat_flutter or any other Stream Chat
package — every widget operates on plain strings, callbacks and controllers, so it drops into any
Flutter app and pairs with any backend or LLM provider. See
Using with Stream Chat below for wiring it up to a Stream Chat channel.
To start, this library includes the following components:
StreamingMessageView — renders text, markdown and code in real time with a
character-by-character animation, similar to ChatGPT.
AITypingIndicatorView — displays the different states of the LLM (thinking, checking external
sources, etc) with animated dots.
ChatComposer — a fully featured prompt composer with attachments, chat options, speech input,
and a send button that turns into a stop button while a response streams.
ChartView — renders line, bar, area, scatter, bubble, pie, histogram and heatmap charts from
the JSON an LLM emits, themed from your app and described to a screen reader.
CodeBlockView — a framed code fence with a language label, a copy button and opt-in syntax
highlighting.
SpeechToTextButton — records voice input and streams the recognized transcript into the
composer.
AIToolRegistry — client-side tool calling, letting the agent reach into your app to open a
screen, show an alert or read a sensor.
This repository also includes a showcase app demonstrating every component, which needs no API key to run.
Our team plans to keep iterating and adding more components over time. If there's a component you use every day in your apps and would like to see added, please open an issue and we will try to add it 😎.
Quick Links
- Register to get an API key for Stream Chat
- Flutter AI Assistant Tutorial
- Flutter Chat Tutorial
- Sample apps — backend-connected samples across Flutter, React, React Native, iOS and Android
📦 Installation
stream_chat_flutter_ai has no dependency on Stream Chat — install it on its own:
dependencies:
stream_chat_flutter_ai: ^0.1.0
⚙️ Usage
StreamingMessageView
Renders markdown text with a character-by-character typewriter animation — ideal for
displaying streaming AI responses as they arrive. Markdown is fully parsed: code fences
get a CodeBlockView (with copy button, and syntax highlighting when you supply a
codeHighlighter), and JSON/chart fences are rendered as interactive charts via
ChartView. A code fence is styled from its opening line onwards, so a block still
being streamed doesn't show raw ``` markers while it arrives.
StreamingMessageView(
text: markdownText, // updated in real time as chunks arrive
typingSpeed: Duration(milliseconds: 10),
onTypewriterStateChanged: (state) {
// TypewriterState.typing | idle | paused | stopped
},
);
AITypingIndicatorView
Shows the current AI state ("Thinking…", "Checking sources…") with animated pulsing dots.
AITypingIndicatorView(
text: 'AI is thinking',
dotColor: Colors.blue,
dotCount: 3,
dotSize: 8,
);
TypewriterController
Low-level controller for driving the typewriter effect independently of
StreamingMessageView. Use with TypewriterBuilder for custom layouts.
final controller = TypewriterController(text: '');
// As new chunks arrive from the LLM:
controller.updateText(accumulatedText);
// In your widget tree:
TypewriterBuilder(
controller: controller,
builder: (context, value, child) => Text(value.text),
);
AIMarkdownBody
The markdown renderer used internally by StreamingMessageView. Use it directly when you
need markdown rendering without the typewriter animation.
AIMarkdownBody(
data: markdownText,
selectable: true, // enables text selection on desktop / web
onTapLink: (text, href, title) { /* handle link tap */ },
);
LaTeX
\(…\) and \[…\] are recognised out of the box, but typesetting them is left to you —
every Flutter TeX renderer brings a sizeable dependency tree, and this package stays
standalone. Supply a mathBuilder and math renders; leave it off and the TeX source shows
as plain text. Both AIMarkdownBody and StreamingMessageView accept it.
// with `flutter_math_fork` in your pubspec
StreamingMessageView(
text: markdownText,
mathBuilder: (context, tex, style, {required inline}) => Math.tex(
tex,
textStyle: style,
mathStyle: inline ? MathStyle.text : MathStyle.display,
),
);
Pass useDollarDelimitersForMath: true to also accept $…$ and $$…$$. It is off by
default because $ collides with currency in ordinary prose.
CodeBlockView
Renders a fenced code block with a dark background, monospace text, an optional language label, and a copy-to-clipboard button.
CodeBlockView(
code: 'void main() => print("hello");',
language: 'dart',
);
Syntax highlighting is opt-in through highlighter, for the same reason mathBuilder
exists: a grammar set is several times the size of this whole package — re_highlight's
194 grammars are ~2.7 MB of Dart source, and because each is a top-level final holding
a tree of constructor calls, nothing tree-shakes the unused ones back out of a host app.
So the package frames and chromes code fences, and takes the tokenizer from you.
example/lib/code_highlighter.dart is a complete implementation over re_highlight,
covering the 31 languages LLMs actually emit (plus the aliases each grammar declares —
js, ts, py, sh, yml, c++, cs, html, …). Copy it, trim the language list
to taste, and pass it in:
StreamingMessageView(
text: message,
codeHighlighter: highlightCode, // example/lib/code_highlighter.dart
);
AIMarkdownBody and CodeBlockView take the same callback, as codeHighlighter and
highlighter respectively. Its contract is small:
TextSpan? highlightCode(String code, String language, TextStyle baseStyle);
language arrives exactly as the fence wrote it — case folding and alias resolution are
yours. Return null for a language you don't cover and the block renders plain
monospace text; that is a normal outcome, not a failure. A highlighter that throws, or
that returns spans whose text doesn't match code, is reported through
FlutterError.onError and the block falls back to the same plain rendering — so a
grammar bug costs the reader colors, never their code.
Without a highlighter, every fence renders as plain monospace text. It stays selectable and horizontally scrollable in all cases.
Your highlighter is not called on every frame of a streaming fence: a re-highlight waits for the code to gain 64 characters, plus one final pass once it stops changing. The characters in between render unhighlighted rather than being withheld, so the block is never truncated — coloring simply trails the newest text by up to about a line. Blocks over 20,000 characters skip highlighting altogether. Prefer a top-level or otherwise hoisted function over an inline closure, so the widget can tell a genuinely new highlighter from a new closure over the same one.
backgroundColor and foregroundColor set the box and the code color, defaulting to
kDefaultCodeBackgroundColor (#1E1E1E) and kDefaultCodeForegroundColor (#D4D4D4);
the label and copy button follow the foreground at reduced opacity. AIMarkdownBody and
StreamingMessageView forward them as codeBackgroundColor / codeForegroundColor.
The block stays dark regardless of the ambient Theme — code reads as code.
ChartView + USpec
Renders a USpec as a line, bar, area, scatter, bubble, pie, histogram or heatmap chart. All but
the heatmap are painted by fl_chart; the heatmap is a plain widget grid. Parse AI responses that
contain JSON chart data with USpecParser:
// Reads USpec, Chart.js, Plotly, ECharts, Highcharts, Vega-Lite and a flat pie schema.
final spec = USpecParser.tryParse(jsonString);
if (spec != null) ChartView(spec: spec);
Theming. Colors and sizes come from ChartThemeData, registered app-wide as part of the
package's AITheme extension:
MaterialApp(
theme: ThemeData(
colorSchemeSeed: brandBlue,
extensions: const [
AITheme(chartTheme: ChartThemeData(seriesColors: [Color(0xFF005FFF), Color(0xFF00C1FF)])),
],
),
...
)
Every field is nullable, and leaving one unset means "derive it from the ambient ThemeData" — so
overriding the palette leaves the grid lines, axis labels and heatmap ramp following your app, in
light and dark alike. The sizing fields (height, the scatter and bubble radii, histogramBinCount)
have no theme to derive from, so they fall back to fixed defaults instead. Override it for a subtree with ChartTheme(data: ..., child: ...), or for
one chart with ChartView(theme: ...); those three compose, most specific first.
Two 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()]).- Pie slice labels pick black or white per slice — whichever has the better WCAG contrast
against that slice's own fill. Set a color on
ChartThemeData.pieLabelStyleto take that choice back. - Nesting
ChartThemereplaces rather than layers, the wayIconThemedoes. UseChartTheme.mergeto add to an enclosing scope instead of shadowing it.
Accessibility. fl_chart paints to a canvas and exposes nothing, so each chart describes
itself: one semantics node whose label summarises the kind, the title, the axes, the series and the
value range — "Bar chart, Messages per day, 5 categories, values 8 to 24". The node deliberately
excludes the subtree, because letting a screen reader walk the tick labels gives a run of bare
numbers with nothing saying which axis they belong to. Because of that exclusion a heatmap names
its rows and columns rather than only counting them — "Heatmap, 2 rows by 4 columns, rows: Mon,
Tue, columns: 9am, 12pm, 3pm, 6pm, values 2 to 14" — since a heatmap rarely has axis labels and
the row and column are what identify a cell. The sentence is composed by
AITranslations.chartSemanticsLabel (see Localization); pass
ChartView(semanticsLabel: ...) to replace it, or an empty string to describe the chart yourself.
ChatComposer
A message composer designed for AI-powered conversations. Features:
- An attachment sheet on the leading "+" button — camera, recent photos, the full photo
library, and the controller's
ChatOptions listed alongside them. - An inline selected-option badge (with dismiss button) inside the input box.
- A send / stop toggle — the send button (↑) becomes a stop button (⏹) while the AI is generating a response.
- An optional voice / send toggle (
enableSpeechToText: true) — a single control that shows a microphone while the field is empty and swaps to send as soon as you type. - Factory-based slot customisation via
ChatComposerFactory— leading, trailing, the input field, and the attachment sheet.
ChatComposer(
controller: controller,
enableSpeechToText: true, // requires the platform permissions documented below
onSendPressed: (text, selectedOption, attachments) async {
await myBackend.sendMessage(text, attachments);
},
onStopPressed: () => myBackend.stopGenerating(),
);
onSendPressed may return a Future — a send is usually a network call. The composer does not
wait for it: the field is cleared as soon as the callback is invoked, not when it completes, so
the composer never sits unresponsive for a round trip. A host whose send can fail owns surfacing
that and re-seeding the composer; a rejected future is at least reported through
FlutterError.onError rather than lost as an unhandled asynchronous error.
ChatComposerController
ChangeNotifier that manages all mutable state for ChatComposer.
final controller = ChatComposerController(
chatOptions: [
ChatOption(id: 'summarize', text: 'Summarize this', icon: Icons.summarize),
ChatOption(id: 'email', text: 'Write an email', icon: Icons.email),
],
);
// While the AI is generating:
controller.isGenerating = true;
// When the response finishes:
controller.isGenerating = false;
ChatComposerFactory
Subclass to override any of the composer's four slots. Each slot receives a props object — a
ChatComposerSlotProps subclass — rather than a bare controller, which is what lets a slot be
handed values ChatComposer itself owns:
| Slot | Props | Default | Returns |
|---|---|---|---|
buildLeading |
ChatComposerLeadingProps |
outlined circular "+" button | Widget? — null hides it |
buildTrailing |
ChatComposerTrailingProps |
null (nothing) |
Widget? — null hides it |
buildInput |
ChatComposerInputProps |
ChatComposerInput |
Widget |
buildAttachmentSheet |
ChatComposerAttachmentSheetProps |
ComposerAttachmentSheet |
Widget |
Every one of those props carries the composer's wiring — controller, focusNode, onSend and
onStop — so any slot can drive the composer, not just the input. ChatComposerInputProps adds
the text-field configuration (hintText, minLines, maxLines, textInputAction,
enableSpeechToText, speechToTextConfig) on top; the other three add nothing and exist to name
the slot.
class MyComposerFactory extends ChatComposerFactory {
// Replace the leading "+" button.
@override
Widget? buildLeading(BuildContext context, ChatComposerLeadingProps props) {
return IconButton(icon: const Icon(Icons.attach_file), onPressed: () { ... });
}
// Keep the default input pill, but decorate around it.
@override
Widget buildInput(BuildContext context, ChatComposerInputProps props) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: ChatComposerInput(props: props),
);
}
// Swap the contents of the sheet the leading button opens. Presentation
// (`showModalBottomSheet`, drag handle) stays with the button, so don't draw
// a drag handle of your own — override `buildLeading` to change how the
// sheet is presented, or whether it is at all.
@override
Widget buildAttachmentSheet(BuildContext context, ChatComposerAttachmentSheetProps props) {
return MyPicker(controller: props.controller);
}
}
// Pass to the composer:
ChatComposer(
factory: MyComposerFactory(),
...
);
Only buildInput's result is rebuilt by the composer. buildAttachmentSheet runs inside a modal
route, outside that rebuild — a replacement sheet rendering controller state (a selection count,
tiles disabled at maxAttachments) has to listen to props.controller itself, as
ComposerAttachmentSheet does.
To replace the input field outright rather than decorate it, build from props:
props.onSend and props.onStop are the only route to ChatComposer.onSendPressed /
onStopPressed — onSend also clears the controller and returns focus to props.focusNode
afterwards, so calling it beats reimplementing the send path. props.onStop is null when the
host passed no onStopPressed, so a custom input can hide its stop control instead of offering a
dead one.
class MyInputFactory extends ChatComposerFactory {
@override
Widget buildInput(BuildContext context, ChatComposerInputProps props) {
return Row(
children: [
Expanded(
child: TextField(
controller: props.controller.textEditingController,
focusNode: props.focusNode,
// `props.hintText` is null unless the host passed
// `ChatComposer.hintText`; the composer leaves the placeholder to
// you. Drop the fallback if your input has a hint of its own.
decoration: InputDecoration(
hintText: props.hintText ?? AITranslations.of(context).composerHint,
),
),
),
IconButton(icon: const Icon(Icons.send), onPressed: props.onSend),
],
);
}
}
buildInput's result is placed in an Expanded by the composer, so don't return an Expanded
yourself (a debug assert catches it). Unlike the leading and trailing slots it is non-nullable —
there is no layout for "no input field"; return const SizedBox.shrink() if you really want an
empty one.
Because the wiring is on the shared base, a control that sends doesn't have to live inside the input at all — the trailing slot is empty by default and takes the same props:
class MySendButtonFactory extends ChatComposerFactory {
@override
Widget? buildTrailing(BuildContext context, ChatComposerTrailingProps props) {
return IconButton(
icon: const Icon(Icons.send),
// `canSend` is the whole rule: there is something to send, and a
// response isn't already streaming. `onSend` no-ops when it is false,
// so wiring the button to it unconditionally leaves it looking enabled
// and doing nothing.
onPressed: props.canSend ? props.onSend : null,
);
}
}
By default the composer refuses to send while controller.isGenerating is true — the same rule
its own UI expresses by showing a stop button instead of a send button. If your backend accepts a
follow-up mid-stream, pass allowSendWhileGenerating: true to ChatComposer; canSend follows
it, so the example above needs no change.
props.onSend and props.onStop are only safe to call while the composer is still mounted.
Calling either across an async gap — after a confirmation dialog, say — once the composer has left
the tree throws an AssertionError in debug and returns without sending in release.
To change one value and keep everything else the host configured, use props.copyWith rather than
re-listing the fields — a field you forget silently reverts to its default:
@override
Widget buildInput(BuildContext context, ChatComposerInputProps props) {
return ChatComposerInput(props: props.copyWith(hintText: 'Ask the docs…'));
}
AISuggestionsView
A horizontally-scrolling row of free-text quick-reply chips, independent of ChatOption. Typically
docked above the composer on a "new chat" landing screen — tapping a chip fires the callback with
its exact text, and you decide what happens next (e.g. send it immediately).
Column(
children: [
const Spacer(),
AISuggestionsView(
suggestions: const [
'Create a painting in Renaissance-style',
'Help me study vocabulary for an exam',
],
onSuggestionSelected: (text) => sendMessage(text),
),
],
)
SpeechToTextButton
A microphone button that feeds partial and final speech recognition results directly
into an ChatComposerController's text field. The button hides itself automatically
when the AI is generating or the text field is non-empty. Prefer
ChatComposer(enableSpeechToText: true, ...) over placing this widget manually — that
swaps it into the send button's own slot for a single toggling control, rather than a
separate button alongside send.
SpeechToTextButton(
controller: controller,
localeId: 'en-US', // optional; defaults to device locale
listenFor: Duration(seconds: 30),
pauseFor: Duration(seconds: 3),
onError: (error) => print(error.errorMsg),
);
Required platform permissions
iOS — ios/Runner/Info.plist:
<key>NSMicrophoneUsageDescription</key>
<string>Microphone access is needed for voice input.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Speech recognition is used to convert your voice to text.</string>
Android — android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
macOS — macos/Runner/Info.plist:
<key>NSMicrophoneUsageDescription</key>
<string>Microphone access is needed for voice input.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Speech recognition is used to convert your voice to text.</string>
macos/Runner/DebugProfile.entitlements (and Release.entitlements):
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
AIToolRegistry
Client-side tools let the AI agent reach into your app: open a screen, present a
native alert, read a sensor. You declare each one as an AIClientTool, register it,
and hand the registrations to your backend; the agent then invokes them by name.
Nothing is returned to the model. A client tool is a side effect, not a function call with a result — the protocol has no channel for reporting one back, so neither does this API.
Declare a tool:
class GreetUserTool implements AIClientTool {
GreetUserTool({required this.onGreet});
final void Function(String message) onGreet;
@override
AIToolDefinition get definition => const AIToolDefinition(
name: 'greetUser',
description: 'Show the user a native greeting',
instructions: 'Use greetUser when the user asks to be greeted.',
parameters: {
'type': 'object',
'properties': {
'name': {'type': 'string'},
},
},
);
@override
List<AIToolAction> handleInvocation(AIToolInvocation invocation) {
final name = invocation.args['name'] as String? ?? 'there';
return [() => onGreet('Hello, $name!')];
}
}
A tool returns its work as AIToolActions rather than performing it, so you decide
when and where it runs — the decision lives in the tool, the effect in your widget
tree. Omit parameters entirely for a tool that takes no arguments.
Register it, and tell your backend:
final registry = AIToolRegistry()
..register(GreetUserTool(onGreet: (message) => showSnackBar(message)));
await http.post(
Uri.parse('$myBackend/register-tools'),
headers: {'content-type': 'application/json'},
body: jsonEncode({'channel_id': channel.cid, 'tools': registry.registrationPayloads()}),
);
That endpoint is yours, not Stream's — this package ships no HTTP client. Its job
is to call the agent SDK's registerClientTools(channelId, tools), which persists
the definitions server-side and re-applies them the next time the channel's agent
starts.
Check the casing your own backend expects.
registrationPayloads()emits camelCase keys (showExternalSourcesIndicator), which is what the reference/register-toolsinchat-ai-samplesreads — it takes the camelCase spelling and treatsshow_external_sources_indicatoras a deprecated fallback. Since the endpoint consuming them is yours, that is a default rather than a rule, and a mismatch fails quietly, as a tool that simply never fires. The payloads are plain maps, so remapping keys is a couple of lines.
Then route invocations. dispatch runs the tool's actions in order, guarding each
one; resolve returns them unrun if you want to schedule them yourself — run those
with runActions to get the same guarding, rather than reimplementing it. dispatch
reports whether a tool was registered under the invoked name — not whether it
succeeded. A false is normal rather than an error: because registrations outlive
the build that made them, an older version of your app can register a tool this build
no longer has; a debug build also prints the name and what is registered, for the
case where the name is mismatched rather than stale. Failures inside a tool go to
FlutterError.onError, and to the registry's onToolError if you pass one — that
callback is how you tell the user something didn't work.
Set showExternalSourcesIndicator: true on a tool that consults something remote and
the agent will report a "checking external sources" state while it runs. This package
never reads the flag; to surface the state, pass your own caption for it to
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:
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:
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()]).disabledIconColorderives from the resolvediconColor. Set onlyiconColorand the disabled states dim to match it, rather than to a dimmedonSurfacethat no longer relates.- The idle microphone uses
sendButtonColor, notrecordingButtonColor. It occupies the send button's slot, and the two are meant to read as one control morphing in place;recordingButtonColorapplies only while it is listening. - Nesting a scope replaces rather than layers, the way
IconThemedoes. UseComposerTheme.merge/SuggestionsTheme.mergeto add to an enclosing scope instead of shadowing it. Both areInheritedThemes, so likeChartThemethey carry across aNavigator— a scope above the composer still reaches the attachment sheet thatshowModalBottomSheetpushes.
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
none and the widgets use DefaultAITranslations and render the English they always have, so this
is opt-in.
Subclass DefaultAITranslations and override only what you're changing:
class DutchTranslations extends DefaultAITranslations {
const DutchTranslations();
@override
String get composerHint => 'Vraag maar';
@override
String get send => 'Verstuur';
@override
String clearOption(String option) => '$option wissen';
}
Then hand them to the app through AITranslationsDelegate, a LocalizationsDelegate like any
other, so Flutter picks the instance for the app's locale and swaps it when the locale changes:
MaterialApp(
localizationsDelegates: const [
AITranslationsDelegate({
'nl': DutchTranslations(),
'de': GermanTranslations(),
}),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: const [Locale('en'), Locale('nl'), Locale('de')],
home: ...,
)
Keys are Locale.toString()'s form: 'nl', or 'pt_BR' for a country-specific one, which takes
precedence over a plain 'pt' entry. Case is part of that form — lowercase language, uppercase
country — because Locale compares its subtags verbatim and never normalizes them, so 'NL' would
match no locale at all. A debug-only assert rejects a key in any other shape rather than leaving it
to render English with no explanation. English itself needs no entry — a locale the delegate has
nothing for renders the defaults — so it is safe to register the delegate with one language in it
and add the rest later. The delegate adds no dependency; GlobalMaterialLocalizations above is
flutter_localizations, which you will already have if you are translating the rest of your app.
The example app under example/ has this wired up — an AITranslationsDelegate carrying a Dutch
translation — so run it on a device set to Dutch to see the placeholder and the tooltips follow.
Most of these strings are Tooltip messages on icon-only buttons — the send, stop, mic, "+", copy
and dismiss controls — which makes them the only accessible label those buttons expose. Translating
them is what makes the composer legible to a screen reader in another locale.
To pin one language over part of the tree instead — a screen that is always in one language, a
preview, a test — wrap it in an AITranslationsScope:
AITranslationsScope(
translations: const DutchTranslations(),
child: ChatComposer(onSendPressed: ...),
)
A scope outranks the delegate, being the narrower of the two. AITranslations.of(context) resolves
the scope first, then the locale's delegate, then the English defaults, and never throws.
Three things worth knowing:
- Give your subclass a
constconstructor and construct it asconst DutchTranslations(). A scope compares instances to decide whether to notify, so a fresh instance built in abuildmethod rebuilds every dependent. Aconstmap of them also makes the delegate itselfconst. ChatComposer.hintTextwins overAITranslations.composerHint. A hint written for one composer is more specific than an app-wide string.- Both reach routes, not just the widgets under them. The delegate's strings sit in
MaterialApp's ownLocalizations, above theNavigator, so every route gets them.AITranslationsScopeis anInheritedTheme, soshowModalBottomSheet,showDialogandshowMenucarry it across theNavigatorthe same way they carry aTheme— a scope directly above aChatComposerstill translates that composer's attachment sheet, and so does one above aComposerAttachmentSheetyou present yourself. Those pushes capture the scope, so swapping it while such a route is open doesn't reach the open route, only the next one. The delegate has no such seam.
chartSemanticsLabel is the odd one out: it takes a ChartSemantics — the facts about a chart,
with their numbers already formatted — and composes a whole sentence, rather than being one short
label. A sentence's word order varies far more between languages than a tooltip's does, so
composing it is the point:
@override
String chartSemanticsLabel(ChartSemantics chart) {
final kind = switch (chart.kind) {
USpecKind.bar => 'Staafdiagram',
USpecKind.pie => 'Cirkeldiagram',
USpecKind.heatmap => 'Heatmap',
_ => 'Grafiek',
};
if (chart.isEmpty) return '$kind, geen gegevens';
final count = switch (chart.kind) {
USpecKind.bar => '${chart.categoryCount} categorieën',
USpecKind.pie => '${chart.pointCount} segmenten',
USpecKind.heatmap => '${chart.rowCount} rijen bij ${chart.columnCount} kolommen',
_ => '${chart.pointCount} punten',
};
return '$kind, $count, waarden ${chart.valueMin} tot ${chart.valueMax}';
}
Switch on chart.kind — it covers all eight USpecKinds, and a label that names the wrong kind is
worse than a generic one. The count fields are not interchangeable either: categoryCount is the x
axis a bar chart draws, pointCount counts plotted points (a pie's slices, a histogram's samples),
and rowCount/columnLabels are a heatmap's grid. Check chart.isEmpty before reading
valueMin/valueMax: they are null for a chart with no points. sizeMin/sizeMax are non-null
only for a bubble chart, the one kind that draws UPoint.size.
Subclassing DefaultAITranslations rather than implementing AITranslations directly means a
string added in a later version arrives as an untranslated default rather than a compile error.
Using with Stream Chat
The components in this package are provider-agnostic: they take plain text, callbacks,
and controllers, so you decide how to source and stream AI responses. If you're building
on top of Stream Chat, here's how the pieces
typically connect to a Channel from stream_chat_flutter. This requires adding
stream_chat_flutter as a dependency of your app (not of this package):
channel.on(EventType.aiIndicatorUpdate).listen((event) {
final state = event.aiState; // AITypingState enum
final msgId = event.aiMessage; // ID of the message being generated
controller.isGenerating = state == AITypingState.generating;
// show AITypingIndicatorView / switch to StreamingMessageView
});
channel.on(EventType.aiIndicatorClear).listen((_) {
controller.isGenerating = false;
// hide the indicator, show the final message
});
Route client-tool invocations to your AIToolRegistry:
channel.on(kClientToolInvocationEventType).listen((event) {
final invocation = AIToolInvocation.tryParse(
{...event.extraData, 'cid': event.cid, 'message_id': event.messageId},
isInvocationEvent: true,
);
if (invocation != null) unawaited(registry.dispatch(invocation));
});
A payload that announced itself as an invocation and then failed to parse is reported
to FlutterError.onError: the tool the agent asked for will not run, and the agent is
never told, so that would otherwise be silent.
tryParse takes a flat map so the event's own fields can be merged in. cid,
message_id and type are all first-class fields on Event rather than part of
extraData, so the two the invocation needs are added by hand — forget message_id
and invocation.messageId is silently null.
type is the third one, and it is what isInvocationEvent: true stands in for: it
tells tryParse that you have already filtered the stream, so a payload that arrives
with no tool object is a malformed invocation worth reporting rather than some other
event to pass over. Filter on an event type of your own and the flag still holds — it
is the filtering it asserts, not the name.
Stop an in-progress AI response:
await channel.stopAIResponse();
Send the composer's text as a new message:
ChatComposer(
controller: controller,
onSendPressed: (text, selectedOption, attachments) => channel.sendMessage(Message(text: text)),
onStopPressed: () => channel.stopAIResponse(),
);
See the Flutter AI sample app for a complete, working integration.
Changelog
Check out the changelog on pub.dev to see the latest changes in the package.
🛥 What is Stream?
Stream allows developers to rapidly deploy scalable feeds, chat messaging and video with an industry leading 99.999% uptime SLA guarantee.
Stream provides UI components and state handling that make it easy to build real-time chat and video calling for your app. Stream runs and maintains a global network of edge servers around the world, ensuring optimal latency and reliability regardless of where your users are located.
📕 Tutorials
To learn more about integrating AI and chatbots into your application, we recommend checking out the full list of tutorials across all of our supported frontend SDKs and providers. Stream's Chat SDK is natively supported across:
The sibling AI component libraries live in stream-chat-android-ai and stream-chat-swift-ai.
👩💻 Free for Makers 👨💻
Stream is free for most side and hobby projects. To qualify, your project/company needs to have < 5 team members and < $10k in monthly revenue. Makers get $100 in monthly credit for video for free. For more details, check out the Maker Account.
💼 We are hiring!
We've recently closed a $38 million Series B funding round and we keep actively growing. Our APIs are used by more than a billion end-users, and you'll have a chance to make a huge impact on the product within a team of the strongest engineers all over the world. Check out our current openings and apply via Stream's website.
License
stream_chat_flutter_ai is released under the Stream Source Code License Agreement. See
LICENSE for the full text.
Libraries
- stream_chat_flutter_ai
- Stream Chat AI Flutter components.