Startseite / Artikel / KI-Chat-App mit Flutter und Gemini: Streaming-Antworten und Riverpod

KI-Chat-App mit Flutter und Gemini: Streaming-Antworten und Riverpod

Einen Flutter-Chatbot auf der Gemini API bauen: Streaming-Antworten, Gedächtnis per ChatSession, State mit Riverpod, sicherer Umgang mit dem API-Schlüssel, typische Fehler und praktische Tipps.

1604 Wörter

Eine Chatfunktion mit der Gemini API in eine Flutter-App einzubauen, ist einer der schnellsten Wege, sie intelligent wirken zu lassen. Unterwegs bleibt man aber leicht am Streaming, am Umgang mit dem API-Schlüssel oder am State-Management hängen. Im Folgenden geht es Schritt für Schritt durch einen funktionierenden Chatbot mit Streaming-Antworten, vom Anlegen des Projekts bis zum Chatbildschirm, dazu die Fehler, über die am häufigsten gestolpert wird, und einige Gewohnheiten, die sich vor dem Release lohnen.

Warum solche Integrationen schiefgehen

Die meisten behandeln einen KI-Chat wie jeden anderen REST-Aufruf: Anfrage schicken, warten, Ergebnis anzeigen. Bei Sprachmodellen bricht das schnell zusammen, aus vier Gründen.

  • Antworten dauern. Ohne Streaming starrt der Nutzer bei jeder Nachricht etwa 3 bis 8 Sekunden auf einen leeren Bildschirm.
  • Chat-State ist verworrener als normaler App-State. Du verfolgst Nachrichtenverlauf, Lade-Flags, Fehler und gestreamte Tokens gleichzeitig.
  • API-Schlüssel werden hartcodiert. Viele Tutorials kleben den Gemini-Schlüssel direkt in die App, was riskant ist, weil Flutter-Apps als kompilierte Binärdateien ausgeliefert werden, die sich per Reverse Engineering zerlegen lassen.
  • Kontext geht verloren. Wird jede Nachricht einzeln geschickt, vergisst Gemini die früheren Runden.

Das Ergebnis ist eine App, die träge, unsicher oder begriffsstutzig wirkt, obwohl das Modell dahinter stark ist.

Der Plan

So wird unten vorgegangen:

  1. Mit Gemini über das offizielle Dart-Paket google_generative_ai kommunizieren.
  2. Die Antwort Token für Token streamen, damit es aussieht, als würde jemand tippen.
  3. Den Verlauf in Geminis ChatSession halten, damit der Kontext erhalten bleibt.
  4. Den State mit Riverpod verwalten, damit die UI reaktiv und gut testbar bleibt.
  5. Den API-Schlüssel aus dem Client heraushalten: Konfiguration über die Umgebung, in Produktion ein Backend-Proxy.

Das Ergebnis verhält sich wie ChatGPT statt wie ein langsames Anfrage-Antwort-Formular.

Schritt 1: Projekt anlegen

Leg ein neues Flutter-Projekt an oder nimm ein vorhandenes:

flutter create ai_chat_gemini
cd ai_chat_gemini

Hol dir einen Gemini-API-Schlüssel in Google AI Studio unter aistudio.google.com und deklariere dann die Abhängigkeiten:

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  google_generative_ai: ^0.4.6
  flutter_riverpod: ^2.5.1
  flutter_dotenv: ^5.1.0

Lade sie herunter:

flutter pub get

Schritt 2: Schlüssel konfigurieren

Bewahre den Schlüssel in einer .env-Datei auf, statt ihn hart zu codieren, und trag diese Datei sofort in .gitignore ein.

# .env
GEMINI_API_KEY=your_api_key_here

Lade ihn in main.dart, beginnend mit den Imports:

import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'chat_screen.dart';

Initialisiere dann dotenv, bevor die App startet, eingepackt in einen 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(),
    );
  }
}

Ein Warnhinweis für alles Öffentliche: Liefere den Gemini-Schlüssel überhaupt nicht im Client aus. Schick die Anfragen über ein kleines Backend, etwa Firebase Cloud Functions oder einen einfachen Node- oder Laravel-Endpunkt, damit der Schlüssel auf dem Server bleibt. Was hier gebaut wird, taugt für Prototypen und interne Tools.

Schritt 3: Kerncode

Das Nachrichtenmodell

Jede Nachricht trägt ihren Text, den Absender und die Angabe, ob sie noch gestreamt wird.

// lib/models/chat_message.dart
class ChatMessage {
  final String text;
  final bool isUser;
  final bool isStreaming;

Mit einer copyWith-Methode aktualisierst du nur den Text oder das 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,
    );
  }
}

Der Gemini-Service

Dieser Teil kapselt Geminis ChatSession, damit der Gesprächskontext über Nachrichten hinweg erhalten bleibt.

// lib/services/gemini_service.dart
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';

Die Klasse erzeugt das Modell mit gemini-1.5-flash, startet einen Chat, stellt einen Stream von Textstücken bereit und bietet eine Reset-Methode:

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();
  }
}

Entscheidend ist, dass startChat() eine ChatSession zurückgibt, die den Nachrichtenverlauf für dich verwaltet. Gemini erinnert sich an frühere Runden, ohne dass du jedes Mal das ganze Gespräch neu schickst.

State mit Riverpod

// lib/providers/chat_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/chat_message.dart';
import '../services/gemini_service.dart';

Der Notifier fügt die Nachricht des Nutzers hinzu, dann eine leere Bot-Nachricht mit Streaming-Flag. Wenn Stücke eintreffen, hängt er sie nur an diese letzte Nachricht an, und wenn der Stream endet, setzt er das Flag zurück:

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));
});

Schritt 4: Der Chatbildschirm

// lib/chat_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers/chat_provider.dart';

Der Bildschirm beobachtet die Nachrichtenliste und rendert eine scrollbare Liste von Sprechblasen, Nutzernachrichten rechts in Indigo und KI-Nachrichten links in Grau, dazu unten ein Textfeld und einen Senden-Button:

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,
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Damit hast du eine Chat-UI mit gestreamten Gemini-Antworten und einer sauberen Trennung zwischen Oberfläche und Geschäftslogik.

Fehler, die immer wieder vorkommen

Den Schlüssel hartcodieren. Jeder, der die APK oder IPA dekompiliert, kann ihn lesen. Nutze in der Entwicklung Umgebungsvariablen und in Produktion einen Backend-Proxy.

ChatSession weglassen. Isolierte generateContent()-Aufrufe geben Gemini kein Gedächtnis für frühere Nachrichten. Nutze startChat(), damit der Kontext erhalten bleibt.

Streaming ignorieren. Wer auf die komplette Antwort wartet, bevor etwas angezeigt wird, lässt die App zäh wirken; Tokens sofort anzuzeigen verbessert die gefühlte Geschwindigkeit spürbar.

Die ganze Liste pro Token neu aufbauen. Nachlässige State-Updates lösen bei jedem Stück teure Rebuilds aus. Halte Updates eng begrenzt, wie im Notifier oben, der nur die letzte Nachricht anfasst.

Keine Fehlerbehandlung. Rate Limits und Netzwerkausfälle passieren. Pack sendMessageStream in try/catch und biete einen erneuten Versuch an, statt still zu scheitern.

Gewohnheiten, die sich lohnen

  • Debounce das Senden, damit niemand auf den Button hämmern kann, während eine Antwort gestreamt wird.
  • Begrenze den Gesprächsverlauf. Lange Sitzungen treiben Tokenverbrauch und Kosten hoch, also kürze ältere Nachrichten bei Bedarf.
  • Nimm gemini-1.5-flash für Tempo und gemini-1.5-pro für anspruchsvolleres Schlussfolgern. Für die meisten Chats ist Flash günstiger und schneller.
  • Füg einen Stopp-Button hinzu, der das Stream-Abo kündigt, wenn jemand eine lange Antwort abbrechen will.
  • Speichere den Verlauf lokal mit Hive oder SQLite, damit Chats einen Neustart überstehen.
  • Setz auf Clean Architecture mit Service, Notifiern und UI in getrennten Schichten, damit ein späterer Wechsel zu OpenAI oder Claude kein Neuschreiben der Oberfläche bedeutet.

Wo dieses Muster passt

Dieselbe Struktur eignet sich für SaaS-Apps, die einen In-App-Assistenten für Support oder Onboarding ergänzen, für mobile Apps mit Schreibhelfern, Zusammenfassern oder Fragen und Antworten, für Admin-Dashboards, in denen Mitarbeitende Daten in normaler Sprache abfragen, und für jedes System, das einen schlanken Chatbot braucht, ohne ML-Infrastruktur aufzubauen. Weil der Gemini-Service von der UI getrennt ist, lässt sich der Chatbildschirm leicht wiederverwenden, für einen Support-Bot, einen internen Wissensassistenten oder ein spezielles KI-Tool in einer größeren App.

Fazit

Du hast jetzt einen Gemini-Chatbot mit Streaming in Flutter, mit sauberem State-Handling, Gesprächskontext und einer reaktionsschnellen UI. Merk dir vier Dinge: Nutze ChatSession für den Kontext, streame statt zu warten, halte den API-Schlüssel in Produktion vom Client fern und trenne die Gemini-Logik mit Riverpod oder dem State-Manager deiner Wahl von der UI. Das ist genug Fundament für einen Release und lässt sich leicht um Persistenz, mehrere Modelle oder eigene System-Prompts erweitern.