●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
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've been shipping wallpaper and ambient apps to the iOS and Android stores as an indie developer since 2014, and the cumulative downloads have crossed 50 million. AdMob revenue is what keeps the lights on, so when I started adding a small Gemini-powered chat feature to one of those apps last year, the question wasn't "how do I build the best AI chat?" — it was "how do I add an AI chat that doesn't drag the AdMob math underwater?"
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:
Gemini API (Flash, in + out): about $4.8
Firestore (chat read/write): about $0.9
Cloud Functions (Web proxy only): about $0.4
AdMob lift from longer sessions: about ¥3,200 (≈ $22)
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.
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.
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.