Sitemap

I Added an AI Chat Feature to a Flutter App in One Weekend. Here’s the Stack.

6 min read4 days ago

--

Friday at 9pm, curiosity, and a strong cup of tea. Sunday night I had a working in-app AI chat with streaming replies and markdown rendering. Here’s exactly what I used and where it hurt.

I was already two weeks into a freelance project when the client mentioned, almost offhand, that a “chat with the app” feature would be nice. I told him I’d prototype it over the weekend and see if it was worth scoping properly. This is the story of that prototype.

Press enter or click to view image in full size

To be clear upfront: this is a toy. It works, it’s fast enough, and it impressed the client. It is absolutely not production-ready, and the last section of this article explains why.

The Stack I Chose

Before touching a single line of code Friday night, I spent about an hour on pub.dev making sure I wasn’t building on abandoned packages. Here’s what I landed on:

  • Chat UI: flutter_chat_ui v2.11.1 (by flyer.chat). I skipped dash_chat_2 because its last release was over two years ago and several features are still marked WIP. flutter_chat_ui is actively maintained, backend-agnostic, and looks good out of the box.
  • AI backend: firebase_ai v3.12.2 (Firebase AI Logic SDK). I originally planned to use google_generative_ai but it's been officially deprecated. The maintainers published a note saying they "don't plan to add anything to this SDK or make any further changes" and pointed everyone to firebase_ai. That made the choice easy.
  • Markdown rendering: flutter_markdown_plus v1.0.7. flutter_markdown was discontinued by Google. Foresight Mobile took it over as flutter_markdown_plus, and it's a drop-in replacement with the same API.
  • Model: gemini-2.5-flash. Best price-to-performance ratio at the time, and the latency on mobile felt right for a chat UI.

Friday Night: Wiring Up the Chat UI

The flutter_chat_ui package uses its own message model (types.TextMessage, etc.) rather than plain strings, which felt verbose at first but turned out to be worth it. The widget handles scroll, avatars, timestamps, and the input field. I just needed to feed it messages.

Here’s the minimal setup:

// pubspec.yaml additions
// flutter_chat_ui: ^2.11.1
// firebase_ai: ^3.12.2
// firebase_core: ^3.x
// flutter_markdown_plus: ^1.0.7

import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final List<types.Message> _messages = [];
final _user = const types.User(id: 'user');
final _bot = const types.User(id: 'gemini');
@override
Widget build(BuildContext context) {
return Scaffold(
body: Chat(
messages: _messages,
onSendPressed: _handleSend,
user: _user,
),
);
}
void _handleSend(types.PartialText message) {
// add user message, then call Gemini
final userMsg = types.TextMessage(
author: _user,
id: DateTime.now().millisecondsSinceEpoch.toString(),
text: message.text,
);
setState(() => _messages.insert(0, userMsg));
_streamGeminiReply(message.text);
}
}

Nothing clever here. The Chat widget is already doing the heavy lifting.

Saturday: Connecting Firebase AI Logic and Streaming

Setting up Firebase took longer than the actual AI code. FlutterFire CLI, google-services.json, the usual ceremony. Once that was done, the SDK itself is genuinely pleasant to use.

Two things worth knowing before you look at code. First, firebase_ai manages chat history for you through its Chat object; you don't need to manually append messages to a history list. Second, the streaming API uses Dart's async streams, so you get a natural await for loop for processing chunks as they arrive.

import 'package:firebase_ai/firebase_ai.dart';

// In your State class:
late final ChatSession _chat;
@override
void initState() {
super.initState();
final model = FirebaseAI.googleAI().generativeModel(
model: 'gemini-2.5-flash',
systemInstruction: Content.system(
'You are a helpful assistant inside a mobile app. '
'Keep replies concise. Use markdown for lists and code.',
),
);
_chat = model.startChat();
}
Future<void> _streamGeminiReply(String userText) async {
// Insert a placeholder bot message we'll update chunk by chunk
final botId = 'bot_${DateTime.now().millisecondsSinceEpoch}';
final placeholder = types.TextMessage(
author: _bot,
id: botId,
text: '',
);
setState(() => _messages.insert(0, placeholder));
final buffer = StringBuffer();
final stream = _chat.sendMessageStream(Content.text(userText));
await for (final chunk in stream) {
buffer.write(chunk.text ?? '');
// Replace the placeholder with accumulated text
final updated = types.TextMessage(
author: _bot,
id: botId,
text: buffer.toString(),
);
setState(() {
final idx = _messages.indexWhere((m) => m.id == botId);
if (idx != -1) _messages[idx] = updated;
});
}
}

The trick with the placeholder message is the part that makes streaming feel right in a chat UI. You insert an empty bubble, then mutate it in place as chunks arrive. The user sees text appearing word by word rather than a blank screen followed by a wall of text. That single setState inside await for is doing all the work.

Sunday Morning: Rendering Markdown in Bot Replies

Gemini happily returns markdown, especially when you tell it to in the system prompt. Code blocks, bullet lists, bold text — all of it arrives as raw markdown strings unless you render it.

flutter_chat_ui lets you pass a custom text message builder, which is exactly the hook you need:

import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';

// Inside the Chat widget:
Chat(
messages: _messages,
onSendPressed: _handleSend,
user: _user,
textMessageBuilder: (message, {required messageWidth, required showName}) {
// Only render markdown for bot messages
if (message.author.id == 'gemini') {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: MarkdownBody(
data: message.text,
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)),
),
);
}
// User messages render as plain text
return Text(message.text);
},
)

One catch: during streaming, MarkdownBody can produce a slightly jumpy layout because it re-parses the markdown on every chunk. For a prototype this is fine. For production you might want to delay markdown rendering until the stream is complete, or use a simpler inline approach mid-stream and switch to full rendering on completion.

What I Deliberately Skipped

Here is where I want to be honest with you, because articles that only show the happy path are a disservice.

There is no authentication on the Gemini calls. In this prototype, the Firebase API key is embedded in the app. Any user who decompiles the APK can extract it and run calls on your quota. Firebase App Check addresses this, but setting it up properly takes a couple of hours.

There is no rate limiting or cost control. A single chatty user could burn through your Firebase AI Logic budget in an afternoon. You need per-user quotas, and you need to wire those up server-side or through Firebase Remote Config rules.

There is no content moderation. I’m passing user input directly to the model with no filtering. For an internal prototype shown to one client, that’s acceptable. For anything public-facing, it isn’t.

There is no error handling beyond what Flutter gives you by default. If the stream throws (network dropout, quota exceeded, model overloaded), the placeholder bubble just stays empty. In production you’d catch errors from the stream, show a retry option, and log the failure somewhere.

Persistence is also missing entirely. Close the app and the conversation is gone. Storing history in Firestore or SQLite is not complicated, but it’s another few hours of work.

The Real Work Starts After the Weekend

Getting a chat UI talking to Gemini is genuinely fast now. The SDK is mature, the packages are good, and the streaming API maps cleanly onto Flutter’s reactive model. I had something working and demo-able in under two days.

But the gap between “working prototype” and “feature you can ship to real users” is not small. App Check, rate limiting, moderation, error handling, persistence, testing under poor network conditions — none of that is hard individually, but together it’s easily two more weeks of careful work.

That’s the honest scope estimate I gave my client on Monday morning. He said yes. Now I actually have to build the thing properly.

Further reading

Muhammad Usman is a senior Flutter developer who has shipped over 50 production apps and publishes open-source packages on pub.dev. He writes as Ottoman Coder.

--

--

Muhammad Usman
Muhammad Usman

Written by Muhammad Usman

I've shipped 50+ Flutter apps and somehow convinced Google to adopt one of my packages. Writing about mobile dev, hard lessons, and Dart internals.