●SUNSET — Six days until the image generation models shut down: the imagen-4.0 family and Gemini 3 Image models stop on August 17●MIGRATE — gemini-3.1-flash-image is the recommended replacement, and it means rewriting generate_images calls as generate_content●CHECK — The same prompt will not necessarily produce the same picture after migrating, so secure any images you still need before the cutoff●CLASSROOM — August 17 is also the day Gemini in Classroom arrives on mobile; the web rollout to students of all ages began on August 10●DEPRECATION — The Grok 4.1 family shuts down on August 20, and gemini-robotics-er-1.6-preview on August 31, succeeded by the er-2 models●CHANGELOG — The Gemini API changelog still ends at July 30. The most recent major change remains the GA of Gemini 3.6 Flash and 3.5 Flash-Lite●SUNSET — Six days until the image generation models shut down: the imagen-4.0 family and Gemini 3 Image models stop on August 17●MIGRATE — gemini-3.1-flash-image is the recommended replacement, and it means rewriting generate_images calls as generate_content●CHECK — The same prompt will not necessarily produce the same picture after migrating, so secure any images you still need before the cutoff●CLASSROOM — August 17 is also the day Gemini in Classroom arrives on mobile; the web rollout to students of all ages began on August 10●DEPRECATION — The Grok 4.1 family shuts down on August 20, and gemini-robotics-er-1.6-preview on August 31, succeeded by the er-2 models●CHANGELOG — The Gemini API changelog still ends at July 30. The most recent major change remains the GA of Gemini 3.6 Flash and 3.5 Flash-Lite
Notes from Adding a Gemini-powered Chat to a Flutter App I Run Solo — design choices and gotchas across iOS and Android
Working notes from layering Gemini API on top of a Flutter app I've been shipping to iOS and Android as a solo indie developer. Covers monthly cost breakdown (Gemini + Firestore + AdMob), how I recover streamed responses that stall on iOS background, and the practical line for free vs. premium tiering — with code and real numbers.
I put off adding a chat feature to one of my Flutter apps for about six months. The reason was simple: that app runs on AdMob revenue, so the moment per-token cost started eating into the ad income I'd have to pull the whole feature back out. When Gemini Flash pricing dropped to where the math finally worked in 2026, I shipped it into one app and ran it in production. These are the notes from that.
That practical constraint is what this article is really about. I'll cover the Flutter + Gemini code you need to get a streaming chat working across iOS and Android, but I'll keep coming back to numbers from the app I actually run: how much Gemini Flash costs me per month at ~8,000 MAU, how Firestore writes blow up if you save streamed deltas live, and where the AdMob increase from longer sessions started showing up.
Setup and context — why I picked Flutter × Gemini as a solo developer
To set expectations: my apps are AdMob-funded, so the first criterion was "can this be added without dropping eCPM?" Flutter lets me target iOS and Android (and the Web build, with caveats I'll mention later) from one codebase — that matters because, as a solo developer running multiple apps, the time cost of maintaining two native chat implementations would have killed the project before launch.
Gemini Flash at $0.075 / 1M input tokens and $0.30 / 1M output tokens (May 2026 pricing) is the part that made the math work. At an average of 800–1,200 tokens per chat session, the marginal cost stays well under the AdMob lift I'd expect from the extra session time. This is roughly the same calculation I ran when I added a Kotlin-native Gemini integration to a different app — see Gemini API × Kotlin — embedding AI in an Android app for the native-side version of this story.
Environment Setup and Project Configuration
Prerequisites
Before you begin, ensure you have the following ready:
Flutter SDK 3.22+ (Dart 3.4+)
A Gemini API key from Google AI Studio
A Firebase project (for authentication and Firestore)
Android Studio or VS Code with Flutter extensions
Creating the Project and Adding Dependencies
Start by creating a Flutter project and adding the required packages:
Create a .env file at the project root and add it to .gitignore:
GEMINI_API_KEY=YOUR_GEMINI_API_KEY
dart run build_runner build
This obfuscates the API key within the compiled binary, providing resistance against reverse engineering.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Real monthly cost breakdown of running Gemini + Firestore in a Flutter app whose revenue is mostly AdMob, with the numbers that actually offset the API spend
✦The Riverpod + AppLifecycleState pattern I use to recover streamed responses that silently stall when iOS suspends the HTTP/2 socket
✦Where AdMob and an AI chat feature can coexist without dragging eCPM down — and the MAU threshold where I'd flip to a paid tier instead
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The core of the application — communication with the Gemini API — is encapsulated in a service class. This cleanly separates UI concerns from business logic.
// lib/services/gemini_service.dartimport 'dart:async';import 'dart:typed_data';import 'package:google_generative_ai/google_generative_ai.dart';import '../env/env.dart';class GeminiService { late final GenerativeModel _model; late final GenerativeModel _visionModel; ChatSession? _chatSession; GeminiService() { // Text chat model _model = GenerativeModel( model: 'gemini-2.5-flash', apiKey: Env.geminiApiKey, generationConfig: GenerationConfig( temperature: 0.7, topP: 0.95, topK: 40, maxOutputTokens: 8192, ), systemInstruction: Content.text( 'You are a helpful and knowledgeable AI assistant. ' 'Provide accurate, clear answers to user questions. ' 'When including code examples, use Markdown formatting.', ), ); // Multimodal model _visionModel = GenerativeModel( model: 'gemini-2.5-flash', apiKey: Env.geminiApiKey, generationConfig: GenerationConfig( temperature: 0.4, maxOutputTokens: 4096, ), ); } /// Start a new chat session void startNewChat({List<Content>? history}) { _chatSession = _model.startChat(history: history ?? []); } /// Send a message with streaming response Stream<String> sendMessageStream(String message) async* { _chatSession ??= _model.startChat(); final response = _chatSession!.sendMessageStream( Content.text(message), ); await for (final chunk in response) { final text = chunk.text; if (text != null && text.isNotEmpty) { yield text; } } } /// Send a message with an image (multimodal) Future<String> sendImageMessage({ required String prompt, required Uint8List imageBytes, required String mimeType, }) async { final content = Content.multi([ TextPart(prompt), DataPart(mimeType, imageBytes), ]); final response = await _visionModel.generateContent([content]); return response.text ?? 'Unable to generate a response.'; } /// Get chat history List<Content>? get chatHistory => _chatSession?.history.toList(); /// Reset the session void resetSession() { _chatSession = null; }}
Three design decisions stand out here.
First, we use separate model instances for text chat and multimodal requests. Text chat needs ChatSession for automatic conversation history management, while image analysis is typically a single-shot request, allowing us to optimize parameters independently.
Second, sendMessageStream returns a Stream<String>. This integrates naturally with Flutter's reactive UI patterns — Riverpod's watch can trigger UI rebuilds as each chunk arrives, creating a real-time typing effect.
Third, we define the model's behavior through systemInstruction. Gemini 2.5 models officially support System Instructions, which are essential for maintaining consistent chat behavior.
State Management — Chat State with Riverpod
Chat app state management is more complex than it appears. The message list, sending state, partial streaming text, and error states all need careful coordination. Riverpod provides the structure we need.
// lib/models/chat_message.dartimport 'dart:typed_data';enum MessageRole { user, assistant }enum MessageStatus { complete, streaming, error }class ChatMessage { final String id; final MessageRole role; final String text; final MessageStatus status; final DateTime timestamp; final Uint8List? imageBytes; const ChatMessage({ required this.id, required this.role, required this.text, this.status = MessageStatus.complete, required this.timestamp, this.imageBytes, }); ChatMessage copyWith({ String? text, MessageStatus? status, }) { return ChatMessage( id: id, role: role, text: text ?? this.text, status: status ?? this.status, timestamp: timestamp, imageBytes: imageBytes, ); }}
The key insight in this design is the streaming message update mechanism. Each time a chunk arrives from sendMessageStream, we update state, which triggers a Riverpod rebuild — no StreamBuilder needed for real-time updates.
One caveat: reassigning state copies the entire list each time, which may impact performance for conversations with hundreds of messages. In such cases, consider extracting the streaming message into a separate StateProvider for optimization.
Building the Chat UI with Streaming Support
The UI layer renders chat messages in real time with Markdown support, ensuring code blocks and formatting display correctly.
The most noteworthy aspect of this UI implementation is the auto-scroll behavior during streaming. By watching messages.last.status == MessageStatus.streaming, we call _scrollToBottom as new chunks arrive, giving users a natural experience of watching the AI "type" its response.
Using MarkdownBody for AI responses means that when Gemini returns Markdown-formatted content — code blocks, lists, bold text — it renders beautifully without any extra work.
Firebase Integration — Authentication and History Persistence
Production apps need user authentication and persistent conversation history. We'll use Firebase Auth for login and Cloud Firestore for data storage.
The Firestore data structure follows a hierarchical pattern: users/{uid}/conversations/{convId}/messages/{msgId}. This design allows Firestore security rules to restrict data under users/{uid} to only that user, ensuring privacy.
Firestore Security Rules
// firestore.rulesrules_version = '2';service cloud.firestore { match /databases/{database}/documents { match /users/{userId}/{document=**} { allow read, write: if request.auth != null && request.auth.uid == userId; } }}
Error Handling and Retry Strategy
In production, you'll encounter network failures, API rate limits, and timeouts. A robust error handling layer is essential.
Exponential backoff increases the retry interval — 1 second, then 2, then 4. When hitting Gemini API rate limits, immediate retries would only make things worse. Spacing them out gives the system time to recover.
Performance Optimization
Response Caching
Calling the API for the same question every time wastes money. Implement a local cache for frequently asked questions:
// lib/services/cache_service.dartimport 'dart:collection';class ResponseCache { static const _maxSize = 100; final _cache = LinkedHashMap<String, _CacheEntry>(); String? get(String prompt) { final entry = _cache[_normalizeKey(prompt)]; if (entry == null) return null; // Only cache entries within 1 hour are valid if (DateTime.now().difference(entry.timestamp).inHours >= 1) { _cache.remove(_normalizeKey(prompt)); return null; } return entry.response; } void put(String prompt, String response) { final key = _normalizeKey(prompt); if (_cache.length >= _maxSize) { _cache.remove(_cache.keys.first); // LRU eviction } _cache[key] = _CacheEntry(response, DateTime.now()); } String _normalizeKey(String prompt) { return prompt.trim().toLowerCase(); }}class _CacheEntry { final String response; final DateTime timestamp; _CacheEntry(this.response, this.timestamp);}
Token Usage Monitoring
Tracking token consumption is important for managing Gemini API costs:
// Get token usage from the responsefinal response = await model.generateContent([content]);final usage = response.usageMetadata;if (usage != null) { print('Input tokens: ${usage.promptTokenCount}'); print('Output tokens: ${usage.candidatesTokenCount}'); print('Total: ${usage.totalTokenCount}');}
Testing Strategy
Production-quality apps need proper testing:
// test/services/gemini_service_test.dartimport 'package:flutter_test/flutter_test.dart';import 'package:mockito/mockito.dart';import 'package:mockito/annotations.dart';// Define an interface for GeminiService to enable mockingabstract class GeminiServiceInterface { Stream<String> sendMessageStream(String message); Future<String> sendImageMessage({ required String prompt, required Uint8List imageBytes, required String mimeType, });}@GenerateMocks([GeminiServiceInterface])void main() { group('ChatNotifier Tests', () { test('Sending a message adds a user message', () { // Test implementation // Use MockGeminiServiceInterface to verify // logic without actual API calls }); test('Status is streaming during response generation', () { // Verify streaming state transitions }); test('Status is error when request fails', () { // Verify error handling }); });}
Tests use mocks instead of calling the Gemini API directly. This lets you run tests in CI/CD pipelines without API keys and incurs no API costs.
What the docs don't tell you — gotchas from running this in production
The code above will get you a working chat, but it's not what kept me up at night. Over the first month of running this in my live Flutter app, the following are the issues I actually had to solve.
iOS background suspends the stream silently
If a user sends a message and then backgrounds the app, iOS suspends the HTTP/2 socket the SDK is reading from. When they come back, neither onError nor onDone fires — the chat is just stuck mid-stream forever. Android did not reproduce this for me, but I keep the recovery code in the shared path because the cost is tiny.
The fix that worked: watch WidgetsBindingObserver and, on AppLifecycleState.resumed, ask the chat notifier whether anything has been "streaming" for more than 5 seconds. If so, discard the in-flight assistant message and re-issue the last user prompt.
To the user this feels like "a couple of seconds of pause, then the response comes back." That is much more acceptable than a frozen UI.
Hiding the cold start behind a small AdMob unit
First responses from Gemini Flash run about 1.6–2.2 seconds from Japan; Pro is closer to 3.5 seconds. On a fresh chat screen, that lag was the single biggest driver of bounce I saw.
What worked for me — and it's a privilege of being the indie developer who controls the whole product — was to fire a short "warm-up" prompt the moment the chat screen opens, and at the same time show one small native AdMob ad. The two seconds of ad gives Gemini time to warm up. AdMob eCPM didn't move in any direction I could detect, and felt time-to-first-token dropped to about 1.5 seconds.
Don't write Firestore on every streamed delta
My first implementation upserted to Firestore on every streamed chunk. The Firestore write bill jumped by roughly an order of magnitude — going from about $0.8 to about $7 per month at the same MAU. None of that data was being read until the chat ended.
The fix is to write to Firestore only on onDone, with the final message and usageMetadata in one document. Intermediate chunks stay in a Hive box on the device.
That single change cut Firestore writes by roughly 90% and pulled the monthly cost back to about $0.9.
generateContentStream is hot — don't double-subscribe
generateContentStream returns a single-subscription stream. If your test code or a second widget tries to .listen to it again, it'll throw. I lost time on this when the chat notifier's tests grabbed the stream twice.
asBroadcastStream() is the obvious escape hatch, but I prefer to keep exactly one subscription inside the ChatNotifier and have UI widgets observe state instead. This makes the cost of UI re-renders explicit and avoids the leak surface.
Web builds leak the key
If you use envied in a Flutter Web build, the obfuscated key still ends up in main.dart.js. For an indie developer who can't always rotate keys quickly, that is a real risk.
My production setup proxies Web traffic through a Cloud Functions endpoint (or Cloud Run for higher throughput) and only calls Gemini directly from iOS and Android, where I can rely on App Attest and Play Integrity to gate requests. The Web bundle never sees the key.
Monthly cost breakdown from my own app
One month of running this on a single Flutter app with about 8,000 MAU, where roughly 12% of MAU actually used the chat at an average of ~1,100 tokens per session:
Line item
Per month
Notes
Gemini API (Flash, in + out)
about $4.8
Chat used by ~12% of MAU
Firestore (chat read/write)
about $0.9
After switching to a single write on onDone
Cloud Functions (Web proxy only)
about $0.4
Mobile calls Gemini directly, so it isn't counted here
AdMob lift from longer sessions
about ¥3,200 (≈ $22)
Measured only on the screens carrying the chat entry point
In other words, the AdMob lift more than covered Gemini + Firestore, so the feature paid for itself in month one. That math is helped by my MAU being small; once MAU is 10× this, Gemini cost grows slightly non-linearly versus the AdMob lift, and I'd switch to a premium-tier model (ad-free + higher AI quota) somewhere around 50,000 MAU.
Responses that never arrive — safety filters and the store-review homework
The first surprise after launch was how often response.text comes back empty in real use. It never happened once while I was typing my own test prompts in development; in the first week of public traffic I logged dozens of them.
There are two distinct ways a response gets stopped. If the input is blocked, candidates comes back empty and only promptFeedback.blockReason is populated. If the generation is blocked, the candidate does exist but its finishReason is safety. Write response.text! without distinguishing the two and the second case throws a null exception that takes the whole chat screen down with it.
// lib/services/gemini_service.dart — treat a blocked response as an exceptionclass ChatBlocked implements Exception { ChatBlocked(this.stage, this.reason); final String stage; // 'prompt' or 'response' final String? reason;}String? unwrap(GenerateContentResponse res) { // A blocked prompt returns no candidates — only promptFeedback final promptBlock = res.promptFeedback?.blockReason; if (promptBlock != null) { throw ChatBlocked('prompt', promptBlock.name); } if (res.candidates.isEmpty) return null; final c = res.candidates.first; // A blocked generation still returns a candidate, with a different finishReason if (c.finishReason == FinishReason.safety || c.finishReason == FinishReason.recitation) { throw ChatBlocked('response', c.finishReason?.name); } return c.text;}
With streaming, finishReason only arrives on the final chunk. That means the user watches text appear for a second or two before you find out it was blocked. I decided not to erase what's already on screen and instead append a one-line note explaining that the answer stopped. Erasing it looks identical to a network failure, and the support messages went up when I tried that. In the notifier I catch ChatBlocked, attach a stoppedReason to the message in state, and let the UI render it as a small grey footnote.
The moment an AI speaks, review criteria change
There's a second piece of homework the Gemini docs won't mention. Once your app shows AI-generated text to users, both stores treat it much like an app handling user-generated content, and the review checklist grows. Here's what I actually had to handle as of 2026.
Area
iOS (App Store)
Android (Google Play)
Filtering objectionable output
Falls under Guideline 1.2. State in review notes that you use the default safetySettings
Falls under the generative-AI policy. Same note applies
In-app reporting
Required. Users must be able to report the AI reply itself
Required. Users must be able to flag generated output
Blocking abusive users
Needed when users interact with each other (not for a 1:1 chat with the model)
Same
Age rating
May require re-answering the questionnaire
Content rating needs resubmission
On the implementation side this was a long-press menu item on AI messages — "Report this reply" — writing the conversation ID, the message, and an optional comment to a separate Firestore collection. Half a day of work. Shipping without it is where review stops you. The age rating is worth revisiting on the same update, since adding a chat feature can change your answers on the questionnaire.
You can loosen safetySettings to change how aggressively the filter fires, but everything you loosen is something you'll have to explain at review time. If you're an indie developer trying to keep operational load down, leaving the filter at its defaults and investing instead in how a blocked response looks and how users report it is the better trade.
How I'd wire this together today
If I were starting this integration from scratch today as an indie developer, I would recommend the following combination:
Model: default to Gemini Flash; escalate to Pro only when "history ≥ 8 turns and image input is present" to avoid the cost cliff.
State management: Riverpod with one ChatNotifier owning the StreamSubscription.
Persistence: write to Firestore only on onDone; keep in-flight chunks in Hive.
Lifecycle: AppLifecycleState.resumed triggers a re-send for any stream stalled longer than 5 seconds.
Web: do not call Gemini directly from Web; go through a Cloud Functions proxy.
Monetisation: stay on AdMob + free user for the base path, and look at a premium subscription only after crossing about 50,000 MAU.
Safety: branch on promptFeedback.blockReason and finishReason separately, keep partial text on screen with a short note, and ship it alongside an in-app report action.
The point of these defaults isn't that they're the only right answer — it's that they're the lightest set of choices I've found that lets an indie developer add a Gemini chat without the operational load swallowing the rest of the product.
Next step — getting your own version into the store
If you're going to ship this, I would start with the minimal google_generative_ai + Riverpod + Hive loop and get streaming + local persistence working before touching AdMob or Firebase. The latter two are easier to isolate — and easier to attribute eCPM movement to — once the chat itself is stable.
For deeper dives in adjacent territory, Gemini API multimodal advanced techniques and Managing AI logic on Flutter × Firebase are companion pieces to this one. Thank you for reading — I hope it's useful to anyone trying to add an AI feature to their own indie app.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.