with one click
flai
// Install and use FlAI AI chat components in Flutter projects. Guides component selection, installation, theming, and provider setup.
// Install and use FlAI AI chat components in Flutter projects. Guides component selection, installation, theming, and provider setup.
| name | flai |
| description | Install and use FlAI AI chat components in Flutter projects. Guides component selection, installation, theming, and provider setup. |
FlAI is a shadcn/ui-style component library for Flutter that gives you production-ready AI chat UI as source code you own. Components are distributed via a Mason-powered CLI -- you install exactly what you need, and the code lives in your project.
Docs: https://getflai.dev
dart pub global activate flai_cli
flai init
This runs the flai_init brick, generating the core foundation into your lib/ directory:
core/theme/ -- FlaiTheme, FlaiColors, FlaiTypography, FlaiRadius, FlaiSpacing, FlaiIconDatacore/models/ -- Message, Conversation, ChatEvent (sealed class), ChatRequestproviders/ai_provider.dart -- Abstract AiProvider interfaceflai.dart -- Barrel export fileflai add chat_screen
flai add message_bubble
flai add input_bar
flai add openai_provider
You can add multiple components at once:
flai add chat_screen message_bubble input_bar streaming_text typing_indicator
| Component | Command | Description |
|---|---|---|
chat_screen | flai add chat_screen | Full chat screen with header, message list, and input bar. Depends on: message_bubble, input_bar, streaming_text, typing_indicator |
message_bubble | flai add message_bubble | Message bubble with user/assistant styling, thinking blocks, tool call chips, citations, streaming cursor, and error retry |
input_bar | flai add input_bar | Text input with send button, attachment support, Enter-to-send on desktop, multi-line growth |
streaming_text | flai add streaming_text | Token-by-token text rendering with blinking cursor. Two modes: stream-driven or text-driven |
typing_indicator | flai add typing_indicator | Animated three-dot bouncing indicator styled as an assistant bubble |
| Component | Command | Description |
|---|---|---|
tool_call_card | flai add tool_call_card | Function/tool call display card with status and arguments |
code_block | flai add code_block | Syntax-highlighted code display with copy-to-clipboard |
thinking_indicator | flai add thinking_indicator | AI reasoning/thinking panel (collapsible) |
citation_card | flai add citation_card | Source attribution card with title, URL, and snippet |
image_preview | flai add image_preview | Image thumbnail with tap-to-zoom |
| Component | Command | Description |
|---|---|---|
conversation_list | flai add conversation_list | Conversation history list with search and selection |
model_selector | flai add model_selector | AI model picker dropdown |
token_usage | flai add token_usage | Token count display (input/output/cache) |
| Provider | Command | Description |
|---|---|---|
openai_provider | flai add openai_provider | OpenAI Chat Completions API with streaming, tool use, and vision. Uses raw HTTP (package:http) |
anthropic_provider | flai add anthropic_provider | Anthropic Messages API with streaming, tool use, extended thinking, and vision. Uses raw HTTP (package:http) |
Here is the minimal code to get a working AI chat screen:
dart pub global activate flai_cli
flai init
flai add chat_screen openai_provider
import 'package:flutter/material.dart';
import 'flai/flai.dart';
import 'flai/components/chat_screen/chat_screen.dart';
import 'flai/components/chat_screen/chat_screen_controller.dart';
import 'flai/providers/openai_provider.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return FlaiTheme(
data: FlaiThemeData.dark(),
child: MaterialApp(
title: 'AI Chat',
theme: ThemeData.dark(),
home: const ChatPage(),
),
);
}
}
class ChatPage extends StatefulWidget {
const ChatPage({super.key});
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
late final ChatScreenController _controller;
@override
void initState() {
super.initState();
_controller = ChatScreenController(
provider: OpenAiProvider(
apiKey: const String.fromEnvironment('OPENAI_API_KEY'),
model: 'gpt-4o',
),
systemPrompt: 'You are a helpful assistant.',
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: FlaiChatScreen(
controller: _controller,
title: 'AI Assistant',
subtitle: 'GPT-4o',
),
);
}
}
flutter run --dart-define=OPENAI_API_KEY=sk-your-key-here
FlAI uses an InheritedWidget-based theme system with semantic color tokens modeled after shadcn/ui.
FlaiThemeData composes five sub-systems:
| Field | Type | Description |
|---|---|---|
colors | FlaiColors | Semantic color tokens (background, foreground, primary, muted, userBubble, etc.) |
icons | FlaiIconData | Semantic icon set (20 icon fields). Defaults to FlaiIconData.material() |
typography | FlaiTypography | Font families and size scale |
radius | FlaiRadius | Border radius tokens |
spacing | FlaiSpacing | Spacing tokens |
| Preset | Factory | Icons | Description |
|---|---|---|---|
| Zinc Light | FlaiThemeData.light() | FlaiIconData.material() | Clean light theme with zinc neutrals |
| Zinc Dark | FlaiThemeData.dark() | FlaiIconData.material() | Dark theme with zinc neutrals |
| iOS | FlaiThemeData.ios() | FlaiIconData.cupertino() | Apple Messages-inspired blue bubbles, iOS system colors, larger radii, Cupertino icons |
| Premium | FlaiThemeData.premium() | FlaiIconData.sharp() | Linear-inspired dark theme with indigo accents, sharp Material icons |
Semantic icon set with 20 fields. Components access icons via FlaiTheme.of(context).icons instead of hardcoding Icons.* or CupertinoIcons.*.
Presets:
FlaiIconData.material() -- Material Design rounded icons (default for light/dark)FlaiIconData.cupertino() -- Apple SF Symbols style (used by ios() preset)FlaiIconData.sharp() -- Material Design sharp icons (used by premium() preset)Icon fields: toolCall, thinking, citation, image, brokenImage, code, copy, check, close, send, attach, search, delete, add, expand, collapse, chat, model, refresh, error
// Override specific icons on a preset
final customTheme = FlaiThemeData.dark().copyWith(
icons: FlaiIconData.material().copyWith(
send: Icons.arrow_upward_rounded,
chat: Icons.forum_rounded,
),
);
Wrap your app (or a subtree) with FlaiTheme:
FlaiTheme(
data: FlaiThemeData.dark(),
child: MaterialApp(
home: ChatPage(),
),
)
All FlAI widgets read their styling via FlaiTheme.of(context).
Create a fully custom theme by constructing FlaiThemeData directly:
final myTheme = FlaiThemeData(
colors: FlaiColors(
background: Color(0xFF0F172A),
foreground: Color(0xFFF8FAFC),
card: Color(0xFF1E293B),
cardForeground: Color(0xFFF8FAFC),
popover: Color(0xFF1E293B),
popoverForeground: Color(0xFFF8FAFC),
primary: Color(0xFF3B82F6),
primaryForeground: Color(0xFFFFFFFF),
secondary: Color(0xFF334155),
secondaryForeground: Color(0xFFF8FAFC),
muted: Color(0xFF334155),
mutedForeground: Color(0xFF94A3B8),
accent: Color(0xFF3B82F6),
accentForeground: Color(0xFFFFFFFF),
destructive: Color(0xFFEF4444),
destructiveForeground: Color(0xFFFFFFFF),
border: Color(0xFF334155),
input: Color(0xFF334155),
ring: Color(0xFF3B82F6),
userBubble: Color(0xFF3B82F6),
userBubbleForeground: Color(0xFFFFFFFF),
assistantBubble: Color(0xFF1E293B),
assistantBubbleForeground: Color(0xFFF8FAFC),
),
icons: FlaiIconData.material(), // or .cupertino(), .sharp(), or custom
typography: FlaiTypography(
fontFamily: 'Inter',
monoFontFamily: 'Fira Code',
base: 15.0,
),
radius: FlaiRadius(sm: 6, md: 10, lg: 16, xl: 20, full: 9999),
spacing: FlaiSpacing(xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48),
);
Use copyWith to tweak an existing preset:
final customDark = FlaiThemeData.dark().copyWith(
colors: FlaiColors.dark().copyWith(
primary: Color(0xFF10B981), // emerald accent
userBubble: Color(0xFF10B981),
userBubbleForeground: Color(0xFFFFFFFF),
),
icons: FlaiIconData.cupertino(), // swap to Cupertino icons
typography: FlaiTypography(fontFamily: 'Inter'),
);
Colors: background, foreground, card, cardForeground, popover, popoverForeground, primary, primaryForeground, secondary, secondaryForeground, muted, mutedForeground, accent, accentForeground, destructive, destructiveForeground, border, input, ring, userBubble, userBubbleForeground, assistantBubble, assistantBubbleForeground
Icons: toolCall, thinking, citation, image, brokenImage, code, copy, check, close, send, attach, search, delete, add, expand, collapse, chat, model, refresh, error
Typography: fontFamily, monoFontFamily, sm (12), base (14), lg (16), xl (20), xxl (24). Methods: bodySmall(), bodyBase(), bodyLarge(), heading(), headingLarge(), mono()
Radius: sm (4), md (8), lg (12), xl (16), full (9999)
Spacing: xs (4), sm (8), md (16), lg (24), xl (32), xxl (48)
import 'flai/providers/openai_provider.dart';
final provider = OpenAiProvider(
apiKey: 'sk-your-key',
model: 'gpt-4o', // default: 'gpt-4o'
// baseUrl: 'https://your-proxy.com/v1', // optional
// organization: 'org-xxx', // optional
);
// Capabilities:
// provider.supportsToolUse == true
// provider.supportsVision == true
// provider.supportsStreaming == true
// provider.supportsThinking == false
import 'flai/providers/anthropic_provider.dart';
final provider = AnthropicProvider(
apiKey: 'sk-ant-your-key',
model: 'claude-sonnet-4-20250514', // default
// thinkingBudgetTokens: 8192, // enable extended thinking
// baseUrl: 'https://your-proxy.com', // optional
);
// Capabilities:
// provider.supportsToolUse == true
// provider.supportsVision == true
// provider.supportsStreaming == true
// provider.supportsThinking == true
final controller = ChatScreenController(
provider: provider,
systemPrompt: 'You are a helpful AI assistant.',
// initialMessages: [...], // optional conversation history
);
// Send a message (streams the response automatically):
await controller.sendMessage('Hello!');
// Cancel streaming:
await controller.cancel();
// Retry last failed message:
await controller.retry();
// Clear conversation:
controller.clearMessages();
// Listen to state changes:
controller.addListener(() {
print('Streaming: ${controller.isStreaming}');
print('Current text: ${controller.streamingText}');
print('Messages: ${controller.messages.length}');
});
Define tools and send them with requests:
final tools = [
ToolDefinition(
name: 'get_weather',
description: 'Get the current weather for a location',
parameters: {
'type': 'object',
'properties': {
'location': {
'type': 'string',
'description': 'City name',
},
},
'required': ['location'],
},
),
];
final request = ChatRequest(
messages: messages,
tools: tools,
);
// Stream and handle tool call events:
await for (final event in provider.streamChat(request)) {
switch (event) {
case TextDelta(:final text):
// Append text to UI
break;
case ToolCallStart(:final id, :final name):
// Show tool call card
break;
case ToolCallDelta(:final id, :final argumentsDelta):
// Update tool call arguments
break;
case ToolCallEnd(:final id):
// Execute tool and send result back
break;
case ChatDone():
// Stream complete
break;
case ChatError(:final error):
// Handle error
break;
default:
break;
}
}
Message(
id: 'unique-id',
role: MessageRole.user, // user, assistant, system, tool
content: 'Hello!',
timestamp: DateTime.now(),
status: MessageStatus.complete, // streaming, complete, error
attachments: [...], // optional
toolCalls: [...], // optional
thinkingContent: '...', // optional (Anthropic thinking)
citations: [...], // optional
usage: UsageInfo(...), // optional
)
The streaming system uses a sealed ChatEvent class for type-safe event handling:
TextDelta(text) -- Incremental text chunkTextDone(fullText) -- Text complete with full contentThinkingStart() -- AI began reasoningThinkingDelta(text) -- Thinking text chunkThinkingEnd() -- Reasoning completeToolCallStart(id, name) -- Tool call initiatedToolCallDelta(id, argumentsDelta) -- Tool call argument chunkToolCallEnd(id) -- Tool call completeUsageUpdate(inputTokens, outputTokens, ...) -- Token usage reportChatDone() -- Stream finishedChatError(error, stackTrace?) -- Error occurredFlaiTheme.of(context) to read styling -- no hardcoded colors or iconstheme.icons.send, theme.icons.copy, etc.AiProvider abstract class defines the interface; implementations use raw HTTP via package:httpChangeNotifier, Stream){{output_dir}} variable controls output locationpackage:httpdart pub global activate flai_cli
flai init
flai add chat_screen openai_provider
FlaiTheme(
data: FlaiThemeData.dark(),
child: MaterialApp(
home: Scaffold(
body: FlaiChatScreen(
controller: ChatScreenController(
provider: OpenAiProvider(
apiKey: const String.fromEnvironment('OPENAI_API_KEY'),
model: 'gpt-4o',
),
systemPrompt: 'You are a helpful assistant.',
),
title: 'AI Chat',
),
),
),
)
final providers = {
'GPT-4o': OpenAiProvider(
apiKey: const String.fromEnvironment('OPENAI_API_KEY'),
model: 'gpt-4o',
),
'Claude': AnthropicProvider(
apiKey: const String.fromEnvironment('ANTHROPIC_API_KEY'),
model: 'claude-sonnet-4-20250514',
),
};
// Swap provider at runtime:
void switchModel(String name) {
_controller.dispose();
setState(() {
_controller = ChatScreenController(
provider: providers[name]!,
systemPrompt: 'You are a helpful assistant.',
);
});
}
final controller = ChatScreenController(
provider: OpenAiProvider(
apiKey: const String.fromEnvironment('OPENAI_API_KEY'),
model: 'gpt-4o',
),
systemPrompt: 'You can look up weather.',
tools: [
ToolDefinition(
name: 'get_weather',
description: 'Get weather for a city',
parameters: {
'type': 'object',
'properties': {
'city': {'type': 'string', 'description': 'City name'},
},
'required': ['city'],
},
),
],
onToolCall: (name, args) async {
if (name == 'get_weather') {
return '{"temp": 72, "condition": "sunny"}';
}
return '{"error": "unknown tool"}';
},
);
final brandTheme = FlaiThemeData.dark().copyWith(
colors: FlaiColors.dark().copyWith(
primary: Color(0xFF10B981),
userBubble: Color(0xFF10B981),
userBubbleForeground: Color(0xFFFFFFFF),
),
icons: FlaiIconData.cupertino(),
typography: FlaiTypography(fontFamily: 'Inter', monoFontFamily: 'Fira Code'),
);
FlaiTheme(
data: brandTheme,
child: MaterialApp(home: ChatPage()),
)
class MyApp extends StatefulWidget {
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
bool _isDark = true;
@override
Widget build(BuildContext context) {
return FlaiTheme(
data: _isDark ? FlaiThemeData.dark() : FlaiThemeData.light(),
child: MaterialApp(
theme: _isDark ? ThemeData.dark() : ThemeData.light(),
home: ChatPage(
onToggleTheme: () => setState(() => _isDark = !_isDark),
),
),
);
}
}
FlaiStreamingText(
text: controller.streamingText,
isStreaming: controller.isStreaming,
style: FlaiTheme.of(context).typography.bodyBase(
color: FlaiTheme.of(context).colors.foreground,
),
)
Or directly from a stream:
FlaiStreamingText.fromStream(
stream: provider.streamChat(request)
.whereType<TextDelta>()
.map((e) => e.text),
onStreamDone: () => print('Done!'),
)
FlaiChatScreen(
controller: controller,
emptyState: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset('assets/logo.png', height: 64),
SizedBox(height: 16),
Text('How can I help you today?'),
],
),
)
Widget build(BuildContext context) {
final theme = FlaiTheme.of(context);
return IconButton(
icon: Icon(theme.icons.send, color: theme.colors.primary),
onPressed: onSend,
);
}
[HINT] Download the complete skill directory including SKILL.md and all related files