stream_chat_flutter_ai 0.1.0
stream_chat_flutter_ai: ^0.1.0 copied to clipboard
Stream Chat AI Flutter components. Build AI-powered chat experiences with Stream.
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_math_fork/flutter_math.dart';
import 'package:stream_chat_flutter_ai/stream_chat_flutter_ai.dart';
import 'package:stream_chat_flutter_ai_example/code_highlighter.dart';
void main() => runApp(const ExampleApp());
/// Charts follow the host app rather than the package's own constants.
///
/// One instance covers light and dark: everything this leaves unset — the grid
/// line, the axis labels, the heatmap ramp — still derives from the ambient
/// `ColorScheme`. Delete the `extensions:` lines below to get the defaults back.
const _chartTheme = ChartThemeData(seriesColors: _brandChartPalette);
/// A six-hue palette seeded on the same blue as the color scheme.
const _brandChartPalette = [
Color(0xFF005FFF),
Color(0xFF00C1FF),
Color(0xFF7A5CFF),
Color(0xFF00D4A0),
Color(0xFFFFB000),
Color(0xFFFF5A5F),
];
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Stream Chat Flutter AI',
theme: ThemeData(
colorSchemeSeed: const Color(0xFF005FFF),
brightness: Brightness.light,
extensions: const [AITheme(chartTheme: _chartTheme)],
),
darkTheme: ThemeData(
colorSchemeSeed: const Color(0xFF005FFF),
brightness: Brightness.dark,
extensions: const [AITheme(chartTheme: _chartTheme)],
),
// English needs no entry: a locale the delegate has nothing for falls
// back to DefaultAITranslations.
localizationsDelegates: const [
AITranslationsDelegate({'nl': DutchTranslations()}),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('en'), Locale('nl')],
home: const AssistantScreen(),
);
}
}
/// A single message in the conversation.
///
/// [isUser] messages render as plain bubbles; assistant messages render
/// through [StreamingMessageView] so markdown, code fences and chart fences
/// are all handled.
class _Message {
const _Message({required this.text, required this.isUser});
final String text;
final bool isUser;
}
/// The canned assistant reply, chosen to exercise every renderer the package
/// ships: markdown text, a fenced code block ([CodeBlockView]) and a chart
/// fence ([ChartView]). There is no backend here — the reply is revealed by
/// [StreamingMessageView]'s typewriter, the same way a real streamed response
/// would arrive.
// Raw so the LaTeX backslashes below read the way an LLM would emit them.
const _cannedReply = r'''
Sure — here's a quick tour of what this package renders.
### Markdown
Regular **bold**, *italic* and `inline code` all work, plus lists:
1. Streaming text with a typewriter effect
2. Syntax-highlighted code blocks
3. Charts from a JSON fence
### Code blocks
```dart
// Highlighting follows the fence's language.
final controller = ChatComposerController();
controller.isGenerating = true;
```
…and another language, to show it isn't hardcoded to one grammar:
```python
def average(values):
"""Mean of a non-empty sequence."""
return sum(values) / len(values)
```
### Some maths
Inline like \(e^{i\pi} + 1 = 0\), or as its own block:
\[
\sum_{i=1}^{n} i = \frac{n(n+1)}{2}
\]
### A chart
```chart
{
"kind": "bar",
"title": "Messages per day",
"beginAtZeroY": true,
"series": [
{
"name": "Messages",
"points": [
{"x": "Mon", "y": 12},
{"x": "Tue", "y": 19},
{"x": "Wed", "y": 8},
{"x": "Thu", "y": 24},
{"x": "Fri", "y": 17}
]
}
]
}
```
A pie, where each slice's label picks black or white for contrast — note the
amber slice:
```chart
{
"kind": "pie",
"title": "Where the time went",
"series": [
{
"name": "Share",
"points": [
{"x": "Writing", "y": 40},
{"x": "Reviewing", "y": 25},
{"x": "Meetings", "y": 18},
{"x": "Waiting on CI", "y": 12},
{"x": "Email", "y": 5}
]
}
]
}
```
And a heatmap, whose ramp inverts between light and dark so the busiest cells
stay the brightest. Its second row names no series, so it falls back to
`AITranslations.unnamedChartSeries`:
```chart
{
"kind": "heatmap",
"title": "Messages by hour",
"series": [
{
"name": "Mon",
"points": [
{"x": "9", "y": 0, "z": 3},
{"x": "12", "y": 0, "z": 9},
{"x": "15", "y": 0, "z": 6},
{"x": "18", "y": 0, "z": 2}
]
},
{
"points": [
{"x": "9", "y": 0, "z": 5},
{"x": "12", "y": 0, "z": 14},
{"x": "15", "y": 0, "z": 11},
{"x": "18", "y": 0, "z": 4}
]
}
]
}
```
Ask me something else, or pick an option from the **+** button.
''';
const _suggestions = [
'Create a painting in Renaissance-style',
'Help me study vocabulary for an exam',
'Plan a three-day trip to Lisbon',
];
class AssistantScreen extends StatefulWidget {
const AssistantScreen({super.key});
@override
State<AssistantScreen> createState() => _AssistantScreenState();
}
class _AssistantScreenState extends State<AssistantScreen> {
late final ChatComposerController _composerController;
final _scrollController = ScrollController();
final _messages = <_Message>[];
/// Non-null while the fake assistant is "thinking", so the pending reply can
/// be cancelled when the user taps stop.
Timer? _thinkingTimer;
/// Non-null while chunks of the reply are still being delivered.
Timer? _streamTimer;
/// Whether the fake backend has finished sending chunks for the current reply.
///
/// The typewriter goes briefly idle whenever it catches up with the chunks
/// received so far, which is *not* the same as the reply being finished — so
/// the composer only leaves its generating state once both are true.
bool _streamComplete = true;
/// How much of the reply each simulated chunk delivers.
static const _chunkSize = 40;
@override
void initState() {
super.initState();
_composerController = ChatComposerController(
chatOptions: const [
ChatOption(
id: 'image',
text: 'Create image',
icon: Icons.image_outlined,
description: 'Visualize anything',
),
ChatOption(
id: 'summarize',
text: 'Summarize',
icon: Icons.summarize_outlined,
description: 'Condense a long text',
),
],
);
}
@override
void dispose() {
_thinkingTimer?.cancel();
_streamTimer?.cancel();
_composerController.dispose();
_scrollController.dispose();
super.dispose();
}
void _send(String text, ChatOption? option, List<XFile> attachments) {
final prefix = option == null ? '' : '[${option.text}] ';
final suffix = attachments.isEmpty ? '' : ' (${attachments.length} image(s))';
final message = '$prefix$text$suffix'.trim();
if (message.isEmpty) return;
setState(() {
_messages.add(_Message(text: message, isUser: true));
_composerController.isGenerating = true;
_streamComplete = false;
});
_composerController.clear();
_scrollToEnd();
// Stand in for a real request: pause on the typing indicator, then start
// streaming the reply in.
_thinkingTimer = Timer(const Duration(milliseconds: 900), _streamReply);
}
/// Stands in for a streaming backend, appending the canned reply to the last
/// message a chunk at a time the way server-sent tokens arrive.
///
/// `StreamingMessageView` is handed the text received *so far* and types out
/// whatever it hasn't shown yet, so the reveal stays smooth even though the
/// chunks land in coarse steps.
void _streamReply() {
if (!mounted) return;
setState(() => _messages.add(const _Message(text: '', isUser: false)));
var delivered = 0;
_streamTimer = Timer.periodic(const Duration(milliseconds: 120), (timer) {
if (!mounted) return timer.cancel();
delivered = min(delivered + _chunkSize, _cannedReply.length);
setState(() {
_messages[_messages.length - 1] = _Message(
text: _cannedReply.substring(0, delivered),
isUser: false,
);
});
_scrollToEnd();
if (delivered == _cannedReply.length) {
timer.cancel();
_streamComplete = true;
}
});
}
void _stop() {
_thinkingTimer?.cancel();
_streamTimer?.cancel();
setState(() {
_streamComplete = true;
_composerController.isGenerating = false;
});
}
void _scrollToEnd() {
// Wait for the new message to be laid out before scrolling to it.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scrollController.hasClients) return;
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('AI Assistant')),
body: SafeArea(
child: Column(
children: [
Expanded(
child: _messages.isEmpty
? const _EmptyState()
: ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: _messages.length,
separatorBuilder: (context, index) => const SizedBox(height: 16),
itemBuilder: (context, index) {
final message = _messages[index];
if (message.isUser) return _UserBubble(text: message.text);
return StreamingMessageView(
text: message.text,
// The package recognises LaTeX but ships no math
// engine; this is the seam where a host supplies one.
mathBuilder: (context, tex, style, {required inline}) => Math.tex(
tex,
textStyle: style,
mathStyle: inline ? MathStyle.text : MathStyle.display,
onErrorFallback: (error) => Text(tex, style: style),
),
// Same arrangement for code: fences are recognised
// and framed by the package, but the grammars that
// color them live here. See `code_highlighter.dart`.
codeHighlighter: highlightCode,
onTypewriterStateChanged: (state) {
// Flip the composer back to "send" once the backend
// has stopped sending chunks *and* the typewriter
// has caught up with the last one.
if (state == TypewriterState.idle && _streamComplete) {
_composerController.isGenerating = false;
}
},
);
},
),
),
ListenableBuilder(
listenable: _composerController,
builder: (context, child) {
if (!_composerController.isGenerating) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: AITypingIndicatorView(
text: 'Thinking',
textStyle: theme.textTheme.bodyMedium,
dotColor: theme.colorScheme.primary,
),
),
);
},
),
if (_messages.isEmpty)
AISuggestionsView(
suggestions: _suggestions,
onSuggestionSelected: (text) => _send(text, null, const []),
),
ChatComposer(
// No `hintText`: passing one would win over the translation.
controller: _composerController,
enableSpeechToText: true,
onSendPressed: _send,
onStopPressed: _stop,
),
],
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.auto_awesome, size: 48, color: theme.colorScheme.primary),
const SizedBox(height: 16),
Text(
'How can I help?',
style: theme.textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Pick a suggestion below, or type a message to see the '
'streaming, code-block and chart renderers.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
],
),
),
);
}
}
class _UserBubble extends StatelessWidget {
const _UserBubble({required this.text});
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Align(
alignment: AlignmentDirectional.centerEnd,
child: Container(
constraints: const BoxConstraints(maxWidth: 320),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onPrimaryContainer),
),
),
);
}
}
/// Extends [DefaultAITranslations] rather than implementing [AITranslations],
/// so anything left out keeps rendering English.
class DutchTranslations extends DefaultAITranslations {
const DutchTranslations();
@override
String get composerHint => 'Vraag maar raak…';
@override
String get send => 'Verstuur';
@override
String get stopGenerating => 'Stop met genereren';
@override
String get removeAttachment => 'Bijlage verwijderen';
@override
String clearOption(String option) => '$option wissen';
@override
String get addPhotos => "Foto's toevoegen";
@override
String get photos => "Foto's";
@override
String get allPhotos => "Alle foto's";
@override
String get allowPhotoAccess => "Geef toegang tot je foto's";
@override
String get takePhoto => 'Maak een foto';
@override
String get voiceInput => 'Spraakinvoer';
@override
String get stopRecording => 'Stop met opnemen';
@override
String get copyCode => 'Kopieer code';
@override
String get codeCopied => 'Gekopieerd!';
}