Build AI Chat Apps with Flutter — Gemini Guide (2026)

I built my first Flutter AI chat feature about eight months ago, for a tutoring feature inside Bacify. I thought it would take an afternoon. It took three days, and two of those days were spent fighting something nobody warns you about: streaming responses that arrive out of order when the user sends a second message before the first one finishes.
So this isn't going to be one of those "in 20 minutes you'll have a chatbot" posts. It's the version I wish existed when I started — the actual architecture, the actual mistakes, and the actual code I'm running today.
If you just want a toy demo that echoes text back, there are a dozen of those already. This is for when you want to ship something real.
Why Gemini and not OpenAI
I get asked this a lot, so let's deal with it up front.
I use Gemini for Flutter apps for one boring reason: Firebase AI Logic. If your app already uses Firebase — and if you're a Flutter dev in 2026, it probably does — you get a secure backend proxy for your AI calls without standing up your own server. Your API key never touches the client. That's not a small thing. I've seen apps get their OpenAI key extracted from the APK within a week of launching and rack up a few hundred dollars in abuse before anyone noticed.
That doesn't mean OpenAI's Dart client is bad — dart_openai is solid, and if your product already lives in the OpenAI ecosystem for other reasons, use it. But if you're starting fresh and want the least amount of infrastructure to babysit, Gemini through Firebase is the path of least regret.
The Real Architecture (Not the Tutorial Version)
Every tutorial I've read builds the chat state directly inside a StatefulWidget with a List<Message> and setState. That works for a demo. It falls apart the moment you need to:
Persist chat history across app restarts
Handle a network drop mid-stream
Let the user cancel a response they don't want to wait for
Show a "thinking" indicator that doesn't flicker
Here's what I actually use — Riverpod for state, a dedicated chat service that wraps Firebase AI Logic, and a message model that can represent partial (still-streaming) content.
Step 1 — Set Up Firebase AI Logic
If you haven't already, add Firebase to your project with the FlutterFire CLI, then enable AI Logic in the Firebase console: Build → AI Logic → Get Started → Gemini Developer API. For a side project or MVP, the Gemini Developer API option is fine. If you're building something you plan to scale, pick the Vertex AI option instead — better quota controls, better enterprise story.
Add the dependencies:
dependencies:
firebase_core: ^3.8.0
firebase_ai: ^1.1.0
flutter_riverpod: ^2.6.1
riverpod_annotation: ^2.3.5
dev_dependencies:
riverpod_generator: ^2.4.3
build_runner: ^2.4.13
Step 2 — The Message Model
This is the part every tutorial skips, and it's the part that actually matters. Your message needs a state, not just text.
enum MessageStatus { sending, streaming, complete, error }
class ChatMessage {
final String id;
final String content;
final bool isUser;
final MessageStatus status;
final DateTime timestamp;
const ChatMessage({
required this.id,
required this.content,
required this.isUser,
required this.status,
required this.timestamp,
});
ChatMessage copyWith({
String? content,
MessageStatus? status,
}) {
return ChatMessage(
id: id,
content: content ?? this.content,
isUser: isUser,
status: status ?? this.status,
timestamp: timestamp,
);
}
}
Notice status. Without it, you can't tell the difference between "the AI is still typing" and "the AI finished and this is genuinely a short reply." I learned this one the hard way — early testers thought the app was broken because a one-word answer looked identical to a stuck request.
Step 3 — The Chat Service
This wraps Firebase AI Logic and does the one thing tutorials never show you: it keeps a single ChatSession alive so Gemini actually remembers what you talked about three messages ago.
// lib/core/ai/chat_service.dart
import 'package:firebase_ai/firebase_ai.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'chat_service.g.dart';
@riverpod
ChatService chatService(ChatServiceRef ref) {
final model = FirebaseAI.googleAI().generativeModel(
model: 'gemini-2.5-flash',
systemInstruction: Content.system(
'You are a helpful, concise assistant. Keep answers short unless '
'the user explicitly asks for detail.',
),
);
return ChatService(model: model);
}
class ChatService {
final GenerativeModel _model;
ChatSession? _session;
ChatService({required GenerativeModel model}) : _model = model;
void startSession({List<Content>? history}) {
_session = _model.startChat(history: history);
}
Stream<String> sendMessageStream(String text) async* {
if (_session == null) startSession();
final response = _session!.sendMessageStream(Content.text(text));
await for (final chunk in response) {
if (chunk.text != null) {
yield chunk.text!;
}
}
}
void dispose() {
_session = null;
}
}
Two things worth calling out. First, the systemInstruction — set the personality once, here, not by prepending it to every message like I see people do. Second, startChat is what gives you memory across turns. If you call generateContent fresh every time instead of reusing a session, the model has amnesia after every message. This is the single most common mistake I see in Flutter AI tutorials, including some of the well-known ones.
Step 4 — The Notifier That Handles Streaming Correctly
This is the part that took me two days to get right. The problem: if a user sends message #2 while message #1 is still streaming, and you're naively appending chunks to "the last message in the list," you'll corrupt both responses together into a garbled mess.
The fix is to track which message ID a stream belongs to, and only append chunks to that specific message.
// lib/features/chat/providers/chat_notifier.dart
@riverpod
class ChatNotifier extends _$ChatNotifier {
@override
List<ChatMessage> build() {
ref.onDispose(() => ref.read(chatServiceProvider).dispose());
return [];
}
Future<void> sendMessage(String text) async {
final userMessage = ChatMessage(
id: const Uuid().v4(),
content: text,
isUser: true,
status: MessageStatus.complete,
timestamp: DateTime.now(),
);
final aiMessageId = const Uuid().v4();
final aiMessage = ChatMessage(
id: aiMessageId,
content: '',
isUser: false,
status: MessageStatus.streaming,
timestamp: DateTime.now(),
);
state = [...state, userMessage, aiMessage];
final service = ref.read(chatServiceProvider);
try {
final buffer = StringBuffer();
await for (final chunk in service.sendMessageStream(text)) {
buffer.write(chunk);
_updateMessage(aiMessageId, buffer.toString(), MessageStatus.streaming);
}
_updateMessage(aiMessageId, buffer.toString(), MessageStatus.complete);
} catch (e) {
_updateMessage(
aiMessageId,
'Something went wrong. Tap to retry.',
MessageStatus.error,
);
}
}
void _updateMessage(String id, String content, MessageStatus status) {
state = [
for (final msg in state)
if (msg.id == id) msg.copyWith(content: content, status: status) else msg,
];
}
}
Because each AI message gets its own UUID the moment the user hits send, updates always land on the right bubble — even if you eventually let users fire off multiple questions before the first one finishes.
Step 5 — The UI
I'm not going to give you a from-scratch ListView.builder — honestly, use the flutter_chat_ui package for the bubble rendering. It handles the annoying stuff (auto-scroll, keyboard avoidance, markdown rendering for code blocks) that isn't worth reinventing. Here's the wiring:
class ChatScreen extends ConsumerWidget {
const ChatScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messages = ref.watch(chatNotifierProvider);
return Scaffold(
appBar: AppBar(title: const Text('Assistant')),
body: Column(
children: [
Expanded(
child: ListView.builder(
reverse: true,
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[messages.length - 1 - index];
return MessageBubble(message: message);
},
),
),
ChatInputField(
onSend: (text) =>
ref.read(chatNotifierProvider.notifier).sendMessage(text),
),
],
),
);
}
}
The MessageBubble widget checks message.status and shows a subtle pulsing cursor while streaming, a small retry icon on error, and just renders markdown normally on complete.
Things Nobody Tells You
Streaming breaks on flaky connections more than you'd think. Mobile networks drop mid-stream constantly — someone walks into an elevator, switches from WiFi to LTE, whatever. Wrap your stream consumption in a try-catch (shown above) and always give the user a retry button on the exact message that failed, not a generic toast that vanishes in three seconds.
I'll be honest — the first two weeks after I shipped this feature, I had no idea how often it was actually failing in the wild, because a caught exception that just shows "tap to retry" doesn't tell you anything about why. I ended up wiring up Sentry to log the underlying exception type whenever the stream throws, which is how I found out a chunk of my failures were timeout errors from users on 2G connections, not actual API problems. If you're shipping an AI feature to real users, get error tracking in before launch, not after — you can't fix what you can't see.
Token limits will bite you in production, not in testing. Your test conversations are short. Real users have 40-message conversations about the same topic, and eventually you hit the context window and Gemini either errors out or starts forgetting the beginning of the conversation. Prune old messages before sending — keep the last 15-20 exchanges and summarize anything older if you need long-term memory.
Don't stream markdown character-by-character into a markdown renderer. If you re-render flutter_markdown on every single token, you'll see visible flicker every time a bold marker or code fence opens before it closes. Buffer at the sentence or line level instead of the raw token level, or use a renderer built for streaming specifically.
Rate limit your send button. The Gemini free tier has a requests-per-minute cap that's easy to hit during testing when you're rapid-firing messages to debug something. Disable the send button while a response is streaming — it's better UX anyway.
What I'd Actually Recommend for Different Use Cases
Your situation | What to use |
|---|---|
Quick prototype, no backend yet |
|
Production app, already using Firebase | Firebase AI Logic ( |
Need voice + video, not just text |
|
Want a pre-built chat UI fast |
|
Need on-device, offline AI |
|
Already committed to OpenAI |
|
Tools I Use to Actually Ship This Stuff
A few things outside the code itself that made shipping an AI chat feature to production less painful:
Sentry — mentioned above, but worth repeating: error tracking for a streaming feature isn't optional. I run the free tier for side projects and it's caught things I never would have found from user reports alone.
Codemagic — Flutter-native CI/CD. I use it to run a build and a quick smoke test on every PR before merging, which matters more once you're touching async streaming code where a small mistake fails silently instead of throwing on save.
RevenueCat — if you're planning to gate your AI chat feature behind a subscription (which, if you're paying per-token to Gemini, you probably should), this handles the subscription logic across iOS and Android so you're not maintaining two separate billing integrations.
None of these are required to follow this guide. But if you're moving from "cool prototype" to "shipped and used by real people," these are the three things I'd set up first.
Some links above are affiliate links — I may earn a small commission if you sign up, at no extra cost to you. I only recommend tools I actually use in my own Flutter projects.
Wrapping Up
The actual AI integration — calling sendMessageStream — is maybe 15 lines of code. Everything else in this guide is about the parts around it: keeping message state correct when things stream out of order, handling the network failing gracefully, and not leaking your API key. That's the 90% of the work that determines whether your chat feature feels solid or feels like a demo.
If you build this and it works, you already have the foundation for a lot of other things — an AI tutor, a support assistant, a journaling companion. The chat loop is the same; only the system prompt changes.
More Flutter guides at bidev.dev — including the Riverpod deep dive this article assumes you're comfortable with, and the Dio + Riverpod API guide if you're pairing this with a custom backend instead of Firebase.
Did this article save you time?
I write these for free. If it helped, a coffee keeps me going — and more articles coming.