ШІ-чат на Flutter із Gemini: потокові відповіді й Riverpod
Збираємо чат-бота на Flutter поверх Gemini API: потокові відповіді, пам'ять через ChatSession, стан на Riverpod, безпечне поводження з API-ключем, типові помилки й практичні поради.
Додати у Flutter-застосунок чат на Gemini API — один із найшвидших способів зробити його «розумним». Але дорогою легко застрягнути на стримінгу, роботі з API-ключем чи керуванні станом. Нижче — повний розбір робочого чат-бота з потоковими відповідями, від створення проєкту до екрана чату, а також помилки, на яких люди спотикаються найчастіше, і звички, які варто завести до релізу.
Чому такі інтеграції йдуть не так
Більшість ставиться до ШІ-чату як до будь-якого іншого REST-виклику: надіслав запит, зачекав, показав результат. З мовними моделями це швидко розвалюється, і причин чотири.
- Відповіді йдуть довго. Без стримінгу після кожного повідомлення користувач приблизно 3–8 секунд дивиться на порожній екран.
- Стан чату заплутаніший за звичайний стан застосунку. Треба одночасно відстежувати історію повідомлень, прапорці завантаження, помилки й потокові токени.
- API-ключі прописують просто в коді. Чимало посібників вставляють ключ Gemini просто в застосунок, а це ризиковано: Flutter-застосунки постачаються скомпільованими бінарниками, які можна розреверсити.
- Контекст зникає. Якщо кожне повідомлення надсилати окремо, Gemini забуває попередні репліки.
Зрештою застосунок здається повільним, небезпечним чи недалеким, хоча модель за ним сильна.
План
Ось підхід, який використано нижче:
- Спілкуватися з Gemini через офіційний Dart-пакет
google_generative_ai. - Стримити відповідь токен за токеном, щоб це виглядало як набирання тексту людиною.
- Зберігати історію в
ChatSessionGemini, щоб контекст не губився. - Керувати станом через Riverpod, щоб UI лишався реактивним і легко тестувався.
- Не тримати API-ключ у клієнті: конфігурація через оточення, а в продакшені — проксі на бекенді.
Результат поводиться як ChatGPT, а не як повільна форма «запит — відповідь».
Крок 1: створюємо проєкт
Створіть новий Flutter-проєкт або візьміть наявний:
flutter create ai_chat_gemini
cd ai_chat_gemini
Отримайте API-ключ Gemini в Google AI Studio на aistudio.google.com, потім оголосіть залежності:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
google_generative_ai: ^0.4.6
flutter_riverpod: ^2.5.1
flutter_dotenv: ^5.1.0
Завантажте їх:
flutter pub get
Крок 2: налаштовуємо ключ
Зберігайте ключ у файлі .env, а не в коді, і одразу додайте цей файл до .gitignore.
# .env
GEMINI_API_KEY=your_api_key_here
Завантажте його в main.dart, почавши з імпортів:
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'chat_screen.dart';
Потім ініціалізуйте dotenv до запуску застосунку, обгорнувши його в 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(),
);
}
}
Застереження для всього публічного: узагалі не постачайте ключ Gemini всередині клієнта. Надсилайте запити через невеликий бекенд, наприклад Firebase Cloud Functions або простий ендпоінт на Node чи Laravel, щоб ключ лишався на сервері. Те, що зібрано тут, годиться для прототипів і внутрішніх інструментів.
Крок 3: основний код
Модель повідомлення
Кожне повідомлення містить текст, відправника й ознаку того, чи ще триває стримінг.
// lib/models/chat_message.dart
class ChatMessage {
final String text;
final bool isUser;
final bool isStreaming;
Метод copyWith дає змогу оновлювати лише текст або прапорець стримінгу:
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,
);
}
}
Сервіс Gemini
Цей фрагмент обгортає ChatSession Gemini, щоб контекст розмови зберігався між повідомленнями.
// lib/services/gemini_service.dart
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
Клас створює модель із gemini-1.5-flash, запускає чат, віддає потік текстових фрагментів і надає метод скидання:
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();
}
}
Головна деталь: startChat() повертає ChatSession, який сам відстежує історію повідомлень. Gemini пам'ятає попередні репліки, і вам не треба щоразу наново надсилати всю розмову.
Стан на Riverpod
// lib/providers/chat_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/chat_message.dart';
import '../services/gemini_service.dart';
Notifier додає повідомлення користувача, потім порожнє повідомлення бота з прапорцем стримінгу. Коли надходять фрагменти, він дописує їх лише в це останнє повідомлення, а коли потік закінчується, знімає прапорець:
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));
});
Крок 4: екран чату
// lib/chat_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers/chat_provider.dart';
Екран стежить за списком повідомлень і малює прокручуваний список бульбашок: повідомлення користувача праворуч, кольором індиго, повідомлення ШІ ліворуч, сірим, а внизу — текстове поле й кнопка надсилання:
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,
),
],
),
),
),
],
),
);
}
}
Так ви отримуєте потоковий UI чату з Gemini й чистий поділ інтерфейсу та бізнес-логіки.
Помилки, що повторюються знову й знову
Ключ просто в коді. Будь-хто, хто декомпілює APK чи IPA, зможе його прочитати. Використовуйте змінні оточення в розробці й проксі на бекенді в продакшені.
Відмова від ChatSession. Ізольовані виклики generateContent() не дають Gemini пам'яті про попередні повідомлення. Використовуйте startChat(), щоб контекст зберігався.
Ігнорування стримінгу. Якщо чекати повної відповіді, перш ніж щось намалювати, застосунок здається повільним, а показ токенів у міру надходження помітно покращує відчутну швидкість.
Перебудова всього списку на кожен токен. Недбалі оновлення стану спричиняють дорогі перебудови на кожен фрагмент. Оновлюйте точково, як у notifier вище, що чіпає лише останнє повідомлення.
Немає обробки помилок. Ліміти запитів і збої мережі трапляються. Обгорніть sendMessageStream у try/catch і запропонуйте повторити спробу, замість того щоб мовчки падати.
Звички, які варто завести
- Зробіть debounce для надсилання, щоб не можна було гатити по кнопці, поки стримиться відповідь.
- Обмежте історію розмови. Довгі сесії збільшують витрату токенів і вартість, тож за потреби обрізайте старі повідомлення.
- Беріть
gemini-1.5-flashзаради швидкості йgemini-1.5-proдля складніших міркувань. Для більшості чатів Flash дешевша й швидша. - Додайте кнопку «стоп», яка скасовує підписку на потік, коли користувач хоче обірвати довгу відповідь.
- Зберігайте історію локально в Hive чи SQLite, щоб чати переживали перезапуск.
- Використовуйте чисту архітектуру із сервісом, notifier'ами й UI в різних шарах, щоб згодом перехід на OpenAI чи Claude не вимагав переписувати інтерфейс.
Де знадобиться цей шаблон
Та сама структура пасує SaaS-застосункам, що додають вбудованого асистента для підтримки чи онбордингу, мобільним застосункам із помічниками для письма, сумаризаторами чи питаннями-відповідями, адмінпанелям, де співробітники запитують дані звичайною мовою, і будь-якій системі, якій потрібен легкий чат-бот без розгортання ML-інфраструктури. Оскільки сервіс Gemini ізольований від UI, екран чату легко перевикористати для бота підтримки, внутрішнього асистента з бази знань чи нішевого ШІ-інструмента всередині великого застосунку.
Підсумки
Тепер у вас є потоковий чат-бот на Gemini у Flutter із нормальною роботою зі станом, контекстом розмови й чутливим UI. Запам'ятайте чотири речі: використовуйте ChatSession для контексту, стримте, а не чекайте, не тримайте API-ключ у клієнті в продакшені й відокремлюйте логіку Gemini від UI за допомогою Riverpod чи будь-якого іншого менеджера стану. Цієї основи досить для релізу, і її легко розширити до збереження історії, кількох моделей чи власних системних промптів.