Artykuł opublikowany po angielsku.
Flutter AI Chat App with Gemini: Streaming Replies and Riverpod
Build a Flutter chatbot on the Gemini API: streaming responses, ChatSession memory, Riverpod state, safe API key handling, common mistakes and practical tips.
Adding a chat feature to a Flutter app with the Gemini API is one of the quickest ways to make it feel smart. It's also easy to get stuck on streaming, API key handling or state management along the way. What follows is a complete walkthrough of a working chatbot with streaming replies, from project creation to the chat screen, plus the mistakes that trip people up most and some habits worth adopting before you ship.
Why these integrations go wrong
Most people treat an AI chat like any other REST call: send a request, wait, show the result. That falls apart quickly with language models, for four reasons.
- Replies take a while. With no streaming, each message leaves the user looking at an empty screen for roughly 3 to 8 seconds.
- Chat state is more tangled than normal app state. You're tracking message history, loading flags, errors and streaming tokens all at once.
- API keys get hardcoded. Plenty of tutorials paste the Gemini key straight into the app, which is risky because Flutter apps ship as compiled binaries that can be reverse-engineered.
- Context disappears. If every message is sent on its own, Gemini forgets earlier turns.
The result is an app that feels laggy, insecure or dim, even though the model behind it is strong.
The plan
Here's the approach used below:
- Talk to Gemini through the official
google_generative_aiDart package. - Stream the reply token by token, so it looks like someone typing.
- Keep history in Gemini's
ChatSession, so context carries over. - Manage state with Riverpod, which keeps the UI reactive and easy to test.
- Keep the API key out of the client using environment-based config, with a backend proxy for production.
The outcome behaves like ChatGPT instead of a slow request-and-response form.
Step 1: Create the project
Start a new Flutter project, or use one you already have:
flutter create ai_chat_gemini
cd ai_chat_gemini
Grab a Gemini API key from Google AI Studio at aistudio.google.com, then declare the dependencies:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
google_generative_ai: ^0.4.6
flutter_riverpod: ^2.5.1
flutter_dotenv: ^5.1.0
Fetch them:
flutter pub get
Step 2: Configure the key
Keep the key in a .env file instead of hardcoding it, and add that file to .gitignore right away.
# .env
GEMINI_API_KEY=your_api_key_here
Load it in main.dart, starting with the imports:
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'chat_screen.dart';
Then initialize dotenv before running the app, wrapped in a ProviderScope:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await dotenv.load(fileName: ".env");
runApp(const ProviderScope(child: MyApp()));
}class MyApp extends StatelessWidget {
const MyApp({super.key}); @override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Gemini AI Chat',
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
home: const ChatScreen(),
);
}
}
A caution for anything public: don't ship the Gemini key inside the client at all. Send requests through a small backend, such as Firebase Cloud Functions or a simple Node or Laravel endpoint, which keeps the key on the server. What you've built here is acceptable for prototypes and in-house tools.
Step 3: Core code
The message model
Each message carries its text, who sent it and whether it's still streaming.
// lib/models/chat_message.dart
class ChatMessage {
final String text;
final bool isUser;
final bool isStreaming;
A copyWith method lets you update just the text or streaming flag:
const ChatMessage({
required this.text,
required this.isUser,
this.isStreaming = false,
}); ChatMessage copyWith({String? text, bool? isStreaming}) {
return ChatMessage(
text: text ?? this.text,
isUser: isUser,
isStreaming: isStreaming ?? this.isStreaming,
);
}
}
The Gemini service
This piece wraps Gemini's ChatSession so conversation context persists across messages.
// lib/services/gemini_service.dart
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
The class creates the model with gemini-1.5-flash, starts a chat, exposes a stream of text chunks and offers a reset method:
class GeminiService {
late final GenerativeModel _model;
late ChatSession _chatSession; GeminiService() {
_model = GenerativeModel(
model: 'gemini-1.5-flash',
apiKey: dotenv.env['GEMINI_API_KEY']!,
);
_chatSession = _model.startChat();
} Stream<String> sendMessageStream(String message) async* {
final response = _chatSession.sendMessageStream(
Content.text(message),
); await for (final chunk in response) {
if (chunk.text != null) {
yield chunk.text!;
}
}
} void resetChat() {
_chatSession = _model.startChat();
}
}
The detail that matters is that startChat() returns a ChatSession that tracks message history for you. Gemini remembers earlier turns without you resending the whole conversation each time.
State with Riverpod
// lib/providers/chat_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/chat_message.dart';
import '../services/gemini_service.dart';
The notifier adds the user's message, then an empty bot message flagged as streaming. As chunks arrive it appends them to that last message only, and when the stream ends it clears the flag:
final geminiServiceProvider = Provider((ref) => GeminiService());class ChatNotifier extends StateNotifier<List<ChatMessage>> {
final GeminiService _geminiService; ChatNotifier(this._geminiService) : super([]); Future<void> sendMessage(String text) async {
state = [...state, ChatMessage(text: text, isUser: true)]; final botMessage = ChatMessage(text: '', isUser: false, isStreaming: true);
state = [...state, botMessage]; String fullResponse = ''; await for (final chunk in _geminiService.sendMessageStream(text)) {
fullResponse += chunk;
final updated = [...state];
updated[updated.length - 1] = updated.last.copyWith(
text: fullResponse,
isStreaming: true,
);
state = updated;
} final finalList = [...state];
finalList[finalList.length - 1] =
finalList.last.copyWith(isStreaming: false);
state = finalList;
}
}final chatProvider =
StateNotifierProvider<ChatNotifier, List<ChatMessage>>((ref) {
return ChatNotifier(ref.read(geminiServiceProvider));
});
Step 4: The chat screen
// lib/chat_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers/chat_provider.dart';
The screen watches the message list and renders a scrolling list of bubbles, with user messages on the right in indigo and AI messages on the left in grey, plus a text field and send button at the bottom:
class ChatScreen extends ConsumerStatefulWidget {
const ChatScreen({super.key}); @override
ConsumerState<ChatScreen> createState() => _ChatScreenState();
}class _ChatScreenState extends ConsumerState<ChatScreen> {
final _controller = TextEditingController();
final _scrollController = ScrollController(); void _handleSend() {
final text = _controller.text.trim();
if (text.isEmpty) return; ref.read(chatProvider.notifier).sendMessage(text);
_controller.clear();
} @override
Widget build(BuildContext context) {
final messages = ref.watch(chatProvider); return Scaffold(
appBar: AppBar(title: const Text('Gemini AI Chat')),
body: Column(
children: [
Expanded(
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(12),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
return Align(
alignment: message.isUser
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.all(12),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: message.isUser
? Colors.indigo
: Colors.grey.shade200,
borderRadius: BorderRadius.circular(14),
),
child: Text(
message.text.isEmpty ? '...' : message.text,
style: TextStyle(
color: message.isUser ? Colors.white : Colors.black87,
),
),
),
);
},
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Ask something...',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _handleSend(),
),
),
IconButton(
icon: const Icon(Icons.send),
onPressed: _handleSend,
),
],
),
),
),
],
),
);
}
}
That gives you a streaming Gemini chat UI with a clean split between interface and business logic.
Mistakes that come up again and again
Hardcoding the key. Anyone who decompiles the APK or IPA can read it. Use environment variables while developing and a backend proxy in production.
Skipping ChatSession. Isolated generateContent() calls give Gemini no memory of earlier messages. Use startChat() so context persists.
Ignoring streaming. Waiting for the complete answer before drawing anything makes the app feel sluggish, and showing tokens as they land noticeably improves perceived speed.
Rebuilding the whole list per token. Careless state updates trigger expensive rebuilds on every chunk. Keep updates narrow, as in the notifier above, which only touches the last message.
No error handling. Rate limits and network failures happen. Wrap sendMessageStream in try/catch and offer a retry instead of failing silently.
Habits worth adopting
- Debounce sends so people can't hammer the button while a reply is streaming.
- Cap conversation history. Long sessions raise token usage and cost, so trim older messages when needed.
- Pick
gemini-1.5-flashfor speed andgemini-1.5-profor harder reasoning. Flash is cheaper and faster for most chat. - Add a stop button that cancels the stream subscription when someone wants to cut a long answer short.
- Save history locally with Hive or SQLite so chats survive restarts.
- Use clean architecture, with the service, notifiers and UI in separate layers, so swapping in OpenAI or Claude later doesn't mean rewriting the interface.
Where this pattern fits
The same structure suits SaaS apps adding an in-app assistant for support or onboarding, mobile apps with writing helpers, summarizers or Q&A, admin dashboards where staff query data in plain language, and any system that needs a lightweight chatbot without standing up ML infrastructure. Because the Gemini service is isolated from the UI, the chat screen is easy to reuse for a support bot, an internal knowledge assistant or a niche AI tool inside a bigger app.
Takeaways
You now have a streaming Gemini chatbot in Flutter with proper state handling, conversation context and a responsive UI. Remember four things: use ChatSession for context, stream instead of waiting, keep the API key off the client in production, and separate Gemini logic from the UI with Riverpod or whichever state manager you prefer. That's enough foundation to ship, and it extends easily to persistence, multiple models or custom system prompts.