Skip to content

Bab 34 — AI Chatbot dengan Gemini

Menambahkan asisten AI ke aplikasi kini relatif sederhana. Yang membedakan chatbot yang terasa bagus dari yang terasa murahan bukan modelnya, melainkan detail pengalaman: respons yang mengalir kata per kata, riwayat yang tersimpan, penanganan kegagalan yang anggun, dan batas pemakaian yang masuk akal.

Bab ini membangun semuanya.

Menyiapkan Gemini

yaml
# pubspec.yaml
dependencies:
  google_generative_ai: ^0.4.6
  cloud_firestore: ^5.5.0
  image_picker: ^1.1.2

Ambil kunci API di aistudio.google.comGet API key.

Kunci API tidak boleh ada di dalam aplikasi

Ini bukan seperti apiKey Firebase yang memang publik. Kunci Gemini memberi akses penuh ke akunmu, dan aplikasi Flutter bisa dibongkar. Siapa pun yang mengekstrak kuncinya bisa memakai kuotamu.

Untuk belajar, --dart-define sudah cukup:

bash
flutter run --dart-define=GEMINI_API_KEY=AIza...

Untuk produksi, panggil Gemini lewat Cloud Function — kunci tetap di server, aplikasi hanya memanggil fungsimu. Ini dibahas di bagian akhir bab.

Panggilan pertama

dart
// lib/data/services/layanan_gemini.dart
import 'dart:io';
import 'package:google_generative_ai/google_generative_ai.dart';

const _kunciApi = String.fromEnvironment('GEMINI_API_KEY');

class KesalahanAi implements Exception {
  KesalahanAi(this.pesan);
  final String pesan;

  @override
  String toString() => pesan;
}

class LayananGemini {
  LayananGemini() : _model = _buatModel();

  final GenerativeModel _model;
  ChatSession? _sesi;

  static GenerativeModel _buatModel() {
    return GenerativeModel(
      model: 'gemini-2.0-flash',
      apiKey: _kunciApi,

      // Instruksi sistem menentukan kepribadian & batasan asisten
      systemInstruction: Content.system(
        'Kamu adalah asisten ramah di dalam aplikasi jajak pendapat '
        'berbahasa Indonesia. Jawab dengan singkat, jelas, dan sopan. '
        'Kalau ditanya hal di luar topik aplikasi, jawab seperlunya lalu '
        'arahkan kembali ke fitur aplikasi. Jangan pernah mengarang data '
        'tentang polling tertentu.',
      ),

      generationConfig: GenerationConfig(
        temperature: 0.7,        // 0 = konsisten, 1 = kreatif
        maxOutputTokens: 800,
        topP: 0.95,
      ),

      safetySettings: [
        SafetySetting(HarmCategory.harassment, HarmBlockThreshold.medium),
        SafetySetting(HarmCategory.hateSpeech, HarmBlockThreshold.medium),
      ],
    );
  }

  /// Mulai sesi baru, opsional dengan riwayat sebelumnya.
  void mulaiSesi({List<Content> riwayat = const []}) {
    _sesi = _model.startChat(history: riwayat);
  }

  /// Kirim pesan dan tunggu jawaban lengkap.
  Future<String> kirim(String pesan) async {
    _sesi ??= _model.startChat();

    try {
      final respons = await _sesi!.sendMessage(Content.text(pesan));
      final teks = respons.text;

      if (teks == null || teks.isEmpty) {
        throw KesalahanAi('Asisten tidak memberikan jawaban.');
      }

      return teks;
    } on GenerativeAIException catch (e) {
      throw KesalahanAi(_terjemahkan(e));
    }
  }

  /// Kirim pesan dan terima jawaban bertahap — terasa jauh lebih hidup.
  Stream<String> kirimBertahap(String pesan) async* {
    _sesi ??= _model.startChat();

    try {
      final aliran = _sesi!.sendMessageStream(Content.text(pesan));

      await for (final potongan in aliran) {
        final teks = potongan.text;
        if (teks != null && teks.isNotEmpty) yield teks;
      }
    } on GenerativeAIException catch (e) {
      throw KesalahanAi(_terjemahkan(e));
    }
  }

  /// Kirim pesan beserta gambar.
  Future<String> kirimDenganGambar(String pesan, File gambar) async {
    try {
      final bytes = await gambar.readAsBytes();

      final respons = await _model.generateContent([
        Content.multi([
          TextPart(pesan),
          DataPart('image/jpeg', bytes),
        ]),
      ]);

      return respons.text ?? 'Tidak ada jawaban.';
    } on GenerativeAIException catch (e) {
      throw KesalahanAi(_terjemahkan(e));
    }
  }

  String _terjemahkan(GenerativeAIException e) {
    final pesan = e.message.toLowerCase();

    if (pesan.contains('api key')) {
      return 'Konfigurasi asisten bermasalah. Hubungi pengembang.';
    }
    if (pesan.contains('quota') || pesan.contains('rate')) {
      return 'Asisten sedang sibuk. Coba lagi sebentar lagi.';
    }
    if (pesan.contains('safety') || pesan.contains('blocked')) {
      return 'Pertanyaan itu tidak bisa dijawab. Coba tanyakan hal lain.';
    }
    if (pesan.contains('network') || pesan.contains('connection')) {
      return 'Periksa koneksi internetmu.';
    }
    return 'Asisten sedang bermasalah. Coba lagi nanti.';
  }
}

Beberapa hal yang layak diperhatikan:

systemInstruction adalah cara paling efektif mengendalikan perilaku asisten. Ia jauh lebih andal daripada menyisipkan instruksi di setiap pesan pengguna.

ChatSession menyimpan riwayat percakapan secara otomatis, sehingga asisten mengingat konteks tanpa kamu mengirim ulang seluruh percakapan.

temperature menentukan variasi jawaban. Untuk asisten bantuan, 0.3–0.7 biasanya tepat. Untuk penulisan kreatif, lebih tinggi.

Model pesan

dart
// lib/data/models/pesan_chat.dart
import 'package:cloud_firestore/cloud_firestore.dart';

enum PengirimPesan { pengguna, asisten }

enum StatusPesan { terkirim, mengetik, gagal }

class PesanChat {
  const PesanChat({
    required this.id,
    required this.teks,
    required this.pengirim,
    required this.padaWaktu,
    this.status = StatusPesan.terkirim,
    this.urlGambar,
  });

  final String id;
  final String teks;
  final PengirimPesan pengirim;
  final DateTime padaWaktu;
  final StatusPesan status;
  final String? urlGambar;

  bool get dariPengguna => pengirim == PengirimPesan.pengguna;

  factory PesanChat.dariDokumen(
    DocumentSnapshot<Map<String, dynamic>> doc,
  ) {
    final data = doc.data()!;
    return PesanChat(
      id: doc.id,
      teks: data['teks'] as String? ?? '',
      pengirim: data['dariAi'] == true
          ? PengirimPesan.asisten
          : PengirimPesan.pengguna,
      padaWaktu:
          (data['padaWaktu'] as Timestamp?)?.toDate() ?? DateTime.now(),
      urlGambar: data['urlGambar'] as String?,
    );
  }

  Map<String, dynamic> toMap() => {
        'teks': teks,
        'dariAi': pengirim == PengirimPesan.asisten,
        'padaWaktu': Timestamp.fromDate(padaWaktu),
        'urlGambar': urlGambar,
      };

  PesanChat copyWith({String? teks, StatusPesan? status}) {
    return PesanChat(
      id: id,
      teks: teks ?? this.teks,
      pengirim: pengirim,
      padaWaktu: padaWaktu,
      status: status ?? this.status,
      urlGambar: urlGambar,
    );
  }
}

Menyimpan riwayat di Firestore

dart
// lib/data/repositories/chat_repository.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import '../models/pesan_chat.dart';
import '../services/layanan_gemini.dart';

class ChatRepository {
  ChatRepository({
    required LayananGemini gemini,
    FirebaseFirestore? firestore,
  })  : _gemini = gemini,
        _db = firestore ?? FirebaseFirestore.instance;

  final LayananGemini _gemini;
  final FirebaseFirestore _db;

  CollectionReference<Map<String, dynamic>> _koleksi(String uid) =>
      _db.collection('chats').doc(uid).collection('messages');

  /// Aliran riwayat waktu nyata.
  Stream<List<PesanChat>> aliranRiwayat(String uid, {int batas = 100}) {
    return _koleksi(uid)
        .orderBy('padaWaktu', descending: true)
        .limit(batas)
        .snapshots()
        .map((s) => s.docs.map(PesanChat.dariDokumen).toList());
  }

  /// Muat riwayat lalu siapkan sesi Gemini dengan konteksnya.
  Future<void> siapkanSesi(String uid) async {
    final snapshot = await _koleksi(uid)
        .orderBy('padaWaktu', descending: true)
        .limit(20)   // batasi konteks — token itu mahal
        .get();

    // Firestore mengembalikan urutan terbaru dulu; Gemini butuh kronologis
    final pesan = snapshot.docs.map(PesanChat.dariDokumen).toList().reversed;

    final riwayat = <Content>[];
    for (final p in pesan) {
      riwayat.add(
        p.dariPengguna
            ? Content.text(p.teks)
            : Content.model([TextPart(p.teks)]),
      );
    }

    _gemini.mulaiSesi(riwayat: riwayat);
  }

  Future<void> simpan(String uid, PesanChat pesan) async {
    await _koleksi(uid).doc(pesan.id).set(pesan.toMap());
  }

  Stream<String> kirimBertahap(String teks) =>
      _gemini.kirimBertahap(teks);

  Future<void> bersihkanRiwayat(String uid) async {
    final snapshot = await _koleksi(uid).get();
    final batch = _db.batch();
    for (final doc in snapshot.docs) {
      batch.delete(doc.reference);
    }
    await batch.commit();
  }
}

Batasi jumlah riwayat yang dikirim

limit(20) di siapkanSesi bukan detail sepele. Setiap pesan dalam riwayat ikut dihitung sebagai token input pada setiap permintaan berikutnya.

Percakapan 200 pesan yang dikirim utuh setiap kali akan menghabiskan kuota dengan sangat cepat, dan responsnya melambat. Dua puluh pesan terakhir biasanya cukup untuk menjaga konteks.

ViewModel chat

dart
// lib/providers/penyedia_chat.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../data/models/pesan_chat.dart';
import '../data/repositories/chat_repository.dart';
import '../data/services/layanan_gemini.dart';

const _uuid = Uuid();

class PenyediaChat extends ChangeNotifier {
  PenyediaChat({required ChatRepository repo, required String uid})
      : _repo = repo,
        _uid = uid;

  final ChatRepository _repo;
  final String _uid;

  final List<PesanChat> _pesan = [];
  bool _asistenMengetik = false;
  bool _siap = false;
  String? _kesalahan;

  List<PesanChat> get pesan => List.unmodifiable(_pesan);
  bool get asistenMengetik => _asistenMengetik;
  bool get siap => _siap;
  String? get kesalahan => _kesalahan;

  Future<void> muatRiwayat() async {
    final snapshot = await _repo.aliranRiwayat(_uid).first;

    _pesan
      ..clear()
      ..addAll(snapshot.reversed);   // kronologis

    await _repo.siapkanSesi(_uid);

    _siap = true;
    notifyListeners();
  }

  Future<void> kirim(String teks) async {
    final bersih = teks.trim();
    if (bersih.isEmpty || _asistenMengetik) return;

    _kesalahan = null;

    // 1. Tampilkan pesan pengguna segera
    final pesanPengguna = PesanChat(
      id: _uuid.v4(),
      teks: bersih,
      pengirim: PengirimPesan.pengguna,
      padaWaktu: DateTime.now(),
    );
    _pesan.add(pesanPengguna);

    // 2. Siapkan wadah kosong untuk jawaban yang akan mengalir
    final idJawaban = _uuid.v4();
    final pesanAsisten = PesanChat(
      id: idJawaban,
      teks: '',
      pengirim: PengirimPesan.asisten,
      padaWaktu: DateTime.now(),
      status: StatusPesan.mengetik,
    );
    _pesan.add(pesanAsisten);

    _asistenMengetik = true;
    notifyListeners();

    // 3. Simpan pesan pengguna tanpa menunggu
    unawaited(_repo.simpan(_uid, pesanPengguna));

    // 4. Terima jawaban bertahap
    final penampung = StringBuffer();
    final indeks = _pesan.length - 1;

    try {
      await for (final potongan in _repo.kirimBertahap(bersih)) {
        penampung.write(potongan);
        _pesan[indeks] = _pesan[indeks].copyWith(teks: penampung.toString());
        notifyListeners();
      }

      _pesan[indeks] = _pesan[indeks].copyWith(status: StatusPesan.terkirim);
      unawaited(_repo.simpan(_uid, _pesan[indeks]));
    } on KesalahanAi catch (e) {
      _kesalahan = e.pesan;
      _pesan[indeks] = _pesan[indeks].copyWith(
        teks: e.pesan,
        status: StatusPesan.gagal,
      );
    } finally {
      _asistenMengetik = false;
      notifyListeners();
    }
  }

  Future<void> ulangiTerakhir() async {
    // Buang jawaban gagal dan pesan pengguna yang memicunya
    if (_pesan.length < 2) return;

    final terakhirPengguna = _pesan[_pesan.length - 2];
    if (!terakhirPengguna.dariPengguna) return;

    _pesan.removeLast();
    _pesan.removeLast();
    notifyListeners();

    await kirim(terakhirPengguna.teks);
  }

  Future<void> bersihkan() async {
    await _repo.bersihkanRiwayat(_uid);
    _pesan.clear();
    await _repo.siapkanSesi(_uid);
    notifyListeners();
  }
}

Pola wadah kosong yang diisi bertahap itu yang membuat efek "mengetik" bekerja. Setiap potongan yang tiba memperbarui teks pesan yang sudah ada di daftar, dan notifyListeners() membuat UI menggambar ulang.

Antarmuka chat

dart
// lib/presentation/screens/layar_chat.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/penyedia_chat.dart';
import '../widgets/gelembung_pesan.dart';

class LayarChat extends StatefulWidget {
  const LayarChat({super.key});

  @override
  State<LayarChat> createState() => _LayarChatState();
}

class _LayarChatState extends State<LayarChat> {
  final _pengendaliTeks = TextEditingController();
  final _pengendaliGulir = ScrollController();

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      context.read<PenyediaChat>().muatRiwayat();
    });
  }

  @override
  void dispose() {
    _pengendaliTeks.dispose();
    _pengendaliGulir.dispose();
    super.dispose();
  }

  void _gulirKeBawah() {
    if (!_pengendaliGulir.hasClients) return;
    _pengendaliGulir.animateTo(
      _pengendaliGulir.position.maxScrollExtent,
      duration: const Duration(milliseconds: 250),
      curve: Curves.easeOut,
    );
  }

  Future<void> _kirim() async {
    final teks = _pengendaliTeks.text;
    if (teks.trim().isEmpty) return;

    _pengendaliTeks.clear();
    await context.read<PenyediaChat>().kirim(teks);

    if (mounted) _gulirKeBawah();
  }

  @override
  Widget build(BuildContext context) {
    final penyedia = context.watch<PenyediaChat>();
    final skema = Theme.of(context).colorScheme;

    // Gulir otomatis saat jawaban mengalir
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (penyedia.asistenMengetik) _gulirKeBawah();
    });

    return Scaffold(
      appBar: AppBar(
        title: const Row(
          children: [
            CircleAvatar(
              radius: 16,
              child: Icon(Icons.auto_awesome, size: 18),
            ),
            SizedBox(width: 12),
            Text('Asisten'),
          ],
        ),
        actions: [
          PopupMenuButton<String>(
            onSelected: (nilai) {
              if (nilai == 'bersihkan') _konfirmasiBersihkan();
            },
            itemBuilder: (_) => const [
              PopupMenuItem(
                value: 'bersihkan',
                child: Text('Hapus Riwayat'),
              ),
            ],
          ),
        ],
      ),

      body: Column(
        children: [
          Expanded(
            child: !penyedia.siap
                ? const Center(child: CircularProgressIndicator())
                : penyedia.pesan.isEmpty
                    ? _SaranAwal(
                        saatDipilih: (teks) {
                          _pengendaliTeks.text = teks;
                          _kirim();
                        },
                      )
                    : ListView.builder(
                        controller: _pengendaliGulir,
                        padding: const EdgeInsets.all(16),
                        itemCount: penyedia.pesan.length,
                        itemBuilder: (ctx, i) => GelembungPesan(
                          pesan: penyedia.pesan[i],
                          saatUlangi: penyedia.ulangiTerakhir,
                        ),
                      ),
          ),

          // Kolom input
          SafeArea(
            child: Container(
              padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
              decoration: BoxDecoration(
                color: skema.surface,
                border: Border(
                  top: BorderSide(color: skema.outlineVariant),
                ),
              ),
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.end,
                children: [
                  Expanded(
                    child: TextField(
                      controller: _pengendaliTeks,
                      minLines: 1,
                      maxLines: 5,
                      textCapitalization: TextCapitalization.sentences,
                      onSubmitted: (_) => _kirim(),
                      decoration: InputDecoration(
                        hintText: 'Tulis pesan...',
                        filled: true,
                        fillColor: skema.surfaceContainerHighest,
                        border: OutlineInputBorder(
                          borderRadius: BorderRadius.circular(24),
                          borderSide: BorderSide.none,
                        ),
                        contentPadding: const EdgeInsets.symmetric(
                          horizontal: 16,
                          vertical: 12,
                        ),
                      ),
                    ),
                  ),
                  const SizedBox(width: 8),
                  IconButton.filled(
                    onPressed: penyedia.asistenMengetik ? null : _kirim,
                    icon: penyedia.asistenMengetik
                        ? const SizedBox(
                            width: 18,
                            height: 18,
                            child: CircularProgressIndicator(strokeWidth: 2),
                          )
                        : const Icon(Icons.send),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }

  Future<void> _konfirmasiBersihkan() async {
    final ya = await showDialog<bool>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: const Text('Hapus riwayat?'),
        content: const Text('Seluruh percakapan akan dihapus permanen.'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(ctx, false),
            child: const Text('Batal'),
          ),
          FilledButton(
            onPressed: () => Navigator.pop(ctx, true),
            style: FilledButton.styleFrom(backgroundColor: Colors.red),
            child: const Text('Hapus'),
          ),
        ],
      ),
    );

    if (ya == true && mounted) {
      await context.read<PenyediaChat>().bersihkan();
    }
  }
}

/// Saran pertanyaan saat percakapan masih kosong —
/// mengurangi kebingungan "aku harus tanya apa".
class _SaranAwal extends StatelessWidget {
  const _SaranAwal({required this.saatDipilih});

  final void Function(String) saatDipilih;

  static const _saran = [
    'Bagaimana cara membuat polling?',
    'Apa itu polling unggulan?',
    'Bantu buatkan ide polling tentang teknologi',
    'Bagaimana cara melihat siapa yang memberi suara?',
  ];

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Icon(Icons.auto_awesome, size: 48),
            const SizedBox(height: 16),
            Text(
              'Ada yang bisa dibantu?',
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(height: 24),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              alignment: WrapAlignment.center,
              children: [
                for (final teks in _saran)
                  ActionChip(
                    label: Text(teks),
                    onPressed: () => saatDipilih(teks),
                  ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Gelembung pesan

dart
// lib/presentation/widgets/gelembung_pesan.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../data/models/pesan_chat.dart';

class GelembungPesan extends StatelessWidget {
  const GelembungPesan({
    super.key,
    required this.pesan,
    required this.saatUlangi,
  });

  final PesanChat pesan;
  final Future<void> Function() saatUlangi;

  @override
  Widget build(BuildContext context) {
    final skema = Theme.of(context).colorScheme;
    final dariPengguna = pesan.dariPengguna;

    // Jawaban masih kosong dan sedang mengetik
    if (!dariPengguna &&
        pesan.teks.isEmpty &&
        pesan.status == StatusPesan.mengetik) {
      return const Align(
        alignment: Alignment.centerLeft,
        child: Padding(
          padding: EdgeInsets.symmetric(vertical: 6, horizontal: 4),
          child: _IndikatorMengetik(),
        ),
      );
    }

    return Align(
      alignment:
          dariPengguna ? Alignment.centerRight : Alignment.centerLeft,
      child: ConstrainedBox(
        constraints: BoxConstraints(
          maxWidth: MediaQuery.sizeOf(context).width * 0.78,
        ),
        child: Container(
          margin: const EdgeInsets.symmetric(vertical: 4),
          padding: const EdgeInsets.symmetric(
            horizontal: 14,
            vertical: 10,
          ),
          decoration: BoxDecoration(
            color: pesan.status == StatusPesan.gagal
                ? skema.errorContainer
                : dariPengguna
                    ? skema.primary
                    : skema.surfaceContainerHighest,
            borderRadius: BorderRadius.only(
              topLeft: const Radius.circular(16),
              topRight: const Radius.circular(16),
              bottomLeft: Radius.circular(dariPengguna ? 16 : 4),
              bottomRight: Radius.circular(dariPengguna ? 4 : 16),
            ),
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: [
              SelectableText(
                pesan.teks,
                style: TextStyle(
                  color: pesan.status == StatusPesan.gagal
                      ? skema.onErrorContainer
                      : dariPengguna
                          ? skema.onPrimary
                          : skema.onSurface,
                  height: 1.4,
                ),
              ),

              if (!dariPengguna && pesan.status == StatusPesan.terkirim)
                Padding(
                  padding: const EdgeInsets.only(top: 4),
                  child: Row(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      IconButton(
                        icon: const Icon(Icons.copy, size: 16),
                        visualDensity: VisualDensity.compact,
                        tooltip: 'Salin',
                        onPressed: () {
                          Clipboard.setData(
                            ClipboardData(text: pesan.teks),
                          );
                          ScaffoldMessenger.of(context)
                            ..clearSnackBars()
                            ..showSnackBar(
                              const SnackBar(
                                content: Text('Disalin'),
                                duration: Duration(seconds: 1),
                              ),
                            );
                        },
                      ),
                    ],
                  ),
                ),

              if (pesan.status == StatusPesan.gagal)
                TextButton.icon(
                  onPressed: saatUlangi,
                  icon: const Icon(Icons.refresh, size: 16),
                  label: const Text('Coba lagi'),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

/// Tiga titik yang berdenyut bergantian.
class _IndikatorMengetik extends StatefulWidget {
  const _IndikatorMengetik();

  @override
  State<_IndikatorMengetik> createState() => _IndikatorMengetikState();
}

class _IndikatorMengetikState extends State<_IndikatorMengetik>
    with SingleTickerProviderStateMixin {
  late final AnimationController _pengendali;

  @override
  void initState() {
    super.initState();
    _pengendali = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 1200),
    )..repeat();
  }

  @override
  void dispose() {
    _pengendali.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final skema = Theme.of(context).colorScheme;

    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
      decoration: BoxDecoration(
        color: skema.surfaceContainerHighest,
        borderRadius: const BorderRadius.only(
          topLeft: Radius.circular(16),
          topRight: Radius.circular(16),
          bottomLeft: Radius.circular(4),
          bottomRight: Radius.circular(16),
        ),
      ),
      child: AnimatedBuilder(
        animation: _pengendali,
        builder: (context, _) {
          return Row(
            mainAxisSize: MainAxisSize.min,
            children: List.generate(3, (i) {
              // Geser fase tiap titik agar bergantian
              final fase = (_pengendali.value + i * 0.25) % 1.0;
              final naik = fase < 0.5 ? fase * 2 : (1 - fase) * 2;

              return Padding(
                padding: const EdgeInsets.symmetric(horizontal: 3),
                child: Transform.translate(
                  offset: Offset(0, -4 * naik),
                  child: CircleAvatar(
                    radius: 4,
                    backgroundColor:
                        skema.onSurface.withValues(alpha: 0.4 + 0.4 * naik),
                  ),
                ),
              );
            }),
          );
        },
      ),
    );
  }
}

Menghasilkan konten dengan AI

Selain menjawab pertanyaan, Gemini bisa membantu pengguna membuat konten. Untuk aplikasi polling, membuatkan opsi jawaban.

dart
// lib/data/services/layanan_gemini.dart — tambahan
Future<List<String>> saranOpsiPolling(String judul) async {
  final prompt = '''
Buatkan 4 opsi jawaban untuk jajak pendapat berjudul: "$judul"

Aturan:
- Setiap opsi maksimal 30 karakter
- Opsi harus saling berbeda dan mencakup kemungkinan utama
- Gunakan Bahasa Indonesia
- Jawab HANYA dengan JSON array, tanpa penjelasan apa pun

Contoh format: ["Opsi A", "Opsi B", "Opsi C", "Opsi D"]
''';

  try {
    final respons = await _model.generateContent([Content.text(prompt)]);
    var teks = respons.text?.trim() ?? '';

    // Model kadang membungkus jawaban dengan ```json ... ```
    teks = teks
        .replaceAll(RegExp(r'^```(?:json)?\s*'), '')
        .replaceAll(RegExp(r'\s*```$'), '')
        .trim();

    final daftar = json.decode(teks) as List<dynamic>;

    return daftar
        .map((e) => e.toString().trim())
        .where((e) => e.isNotEmpty)
        .take(4)
        .toList();
  } catch (_) {
    // Kalau format tidak sesuai, jangan gagalkan alur —
    // kembalikan daftar kosong dan biarkan pengguna mengisi sendiri
    return [];
  }
}

Selalu antisipasi keluaran yang tidak sesuai format

Model bahasa tidak menjamin keluarannya valid JSON, meskipun kamu memintanya dengan tegas. Ia bisa menambahkan penjelasan, membungkus dengan blok kode, atau memakai tanda kutip tunggal.

Karena itu: bersihkan keluarannya, bungkus dengan try/catch, dan selalu sediakan jalur cadangan yang tidak menggagalkan seluruh alur.

Gemini juga mendukung mode keluaran terstruktur lewat responseSchema di GenerationConfig, yang jauh lebih andal untuk kebutuhan ini.

Memakainya di formulir:

dart
Future<void> _mintaSaran() async {
  final judul = _pengendaliJudul.text.trim();

  if (judul.length < 5) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Tulis judul polling terlebih dahulu.')),
    );
    return;
  }

  setState(() => _memuatSaran = true);

  final saran = await _gemini.saranOpsiPolling(judul);

  if (!mounted) return;
  setState(() {
    _memuatSaran = false;
    if (saran.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Gagal membuat saran. Coba lagi.')),
      );
      return;
    }
    for (var i = 0; i < saran.length && i < _pengendaliOpsi.length; i++) {
      _pengendaliOpsi[i].text = saran[i];
    }
  });
}

Perhatikan bahwa saran mengisi kolom yang bisa diedit, bukan langsung disimpan. Pengguna tetap memegang kendali — prinsip penting untuk fitur AI.

Alur lengkap

Menjaga kunci API di produksi

Untuk aplikasi sungguhan, pindahkan panggilan Gemini ke Cloud Function.

javascript
// functions/index.js
const { onCall, HttpsError } = require('firebase-functions/v2/https');
const { defineSecret } = require('firebase-functions/params');
const { GoogleGenerativeAI } = require('@google/generative-ai');

const kunciGemini = defineSecret('GEMINI_API_KEY');

exports.tanyaAsisten = onCall(
  { secrets: [kunciGemini], maxInstances: 10 },
  async (request) => {
    // Hanya pengguna yang sudah masuk
    if (!request.auth) {
      throw new HttpsError('unauthenticated', 'Silakan masuk terlebih dahulu.');
    }

    const pesan = request.data?.pesan;
    if (typeof pesan !== 'string' || pesan.trim().length === 0) {
      throw new HttpsError('invalid-argument', 'Pesan tidak boleh kosong.');
    }
    if (pesan.length > 2000) {
      throw new HttpsError('invalid-argument', 'Pesan terlalu panjang.');
    }

    const genAI = new GoogleGenerativeAI(kunciGemini.value());
    const model = genAI.getGenerativeModel({
      model: 'gemini-2.0-flash',
      systemInstruction:
        'Kamu adalah asisten ramah di aplikasi jajak pendapat berbahasa ' +
        'Indonesia. Jawab singkat dan sopan.',
    });

    const hasil = await model.generateContent(pesan);
    return { jawaban: hasil.response.text() };
  }
);

Memanggilnya dari Flutter:

dart
import 'package:cloud_functions/cloud_functions.dart';

Future<String> tanyaLewatServer(String pesan) async {
  final fungsi = FirebaseFunctions.instanceFor(region: 'asia-southeast1')
      .httpsCallable('tanyaAsisten');

  try {
    final hasil = await fungsi.call<Map<String, dynamic>>({'pesan': pesan});
    return hasil.data['jawaban'] as String;
  } on FirebaseFunctionsException catch (e) {
    throw KesalahanAi(
      switch (e.code) {
        'unauthenticated' => 'Silakan masuk terlebih dahulu.',
        'invalid-argument' => e.message ?? 'Pesan tidak valid.',
        'resource-exhausted' => 'Batas pemakaian tercapai.',
        _ => 'Asisten sedang bermasalah.',
      },
    );
  }
}

Keuntungannya melampaui keamanan kunci: kamu bisa menerapkan batas pemakaian per pengguna, mencatat penggunaan, dan mengganti model tanpa merilis ulang aplikasi.

Membatasi pemakaian

Panggilan AI berbiaya. Batasi agar satu pengguna tidak menghabiskan kuota.

javascript
// functions/index.js — tambahan di dalam tanyaAsisten
const { getFirestore, FieldValue, Timestamp } = require('firebase-admin/firestore');

const db = getFirestore();
const uid = request.auth.uid;

const refKuota = db.doc(`quotas/${uid}`);
const snap = await refKuota.get();
const sekarang = Date.now();
const data = snap.data() || {};

const reset = data.resetPada?.toMillis?.() ?? 0;

// Reset penghitung setiap 24 jam
if (sekarang > reset) {
  await refKuota.set({
    jumlah: 1,
    resetPada: Timestamp.fromMillis(sekarang + 24 * 60 * 60 * 1000),
  });
} else {
  if ((data.jumlah ?? 0) >= 50) {
    throw new HttpsError(
      'resource-exhausted',
      'Batas 50 pesan per hari tercapai. Coba lagi besok.'
    );
  }
  await refKuota.update({ jumlah: FieldValue.increment(1) });
}

Latihan Mandiri

Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.

Variasi 1: Asisten Bertopik Sempit — ⭐⭐ · 45–60 menit

Tantangan: Buat satu asisten dengan ruang lingkup sempit — pemandu tanaman hias, penjelas istilah keuangan, apa pun — yang menolak dengan sopan kalau ditanya di luar topiknya. Lalu tambahkan satu tombol yang meminta model mengembalikan daftar terstruktur, dan buat aplikasi tetap masuk akal ketika model mengembalikan format yang salah.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  ┌─────────────────────────────┐  │
│  │ Kamu: Cara rawat kaktus?     │  │
│  │ Asisten: Siram sebulan       │  │
│  │  sekali saat tanah kering... │  │
│  │ Kamu: Siapa presiden RI?     │  │
│  │ Asisten: Maaf, aku hanya     │  │
│  │  bisa bantu soal tanaman     │  │
│  │  hias 🌵                     │  │
│  └─────────────────────────────┘  │
│  [Ketik pesan...]       [Kirim]    │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Jawaban "Siram sebulan sekali..." muncul kata demi kata (bukan sekaligus), dan pertanyaan di luar topik ditolak dengan sopan alih- alih dijawab asal.

Kriteria selesai:

  • Kepribadian dan batasan diatur lewat systemInstruction, bukan dengan menempelkan instruksi ke setiap pesan pengguna.
  • Jawaban mengalir kata demi kata dengan sendMessageStream, dan pesan pengguna muncul seketika tanpa menunggu jawaban.
  • Jumlah pesan riwayat yang dikirim ke model dibatasi, dan kamu bisa menjelaskan biayanya kalau tidak dibatasi.
  • Keluaran terstruktur dibersihkan dari pembungkus blok kode, dibungkus try/catch, dan punya jalur cadangan yang tidak membuat aplikasi berhenti.
  • Kunci API tidak ditulis di dalam kode.

Petunjuk: Pola implementasi streaming yang membuat semuanya jauh lebih sederhana: tambahkan wadah pesan kosong ke daftar begitu permintaan dikirim, lalu isi isinya bertahap sambil memberi tahu pendengar. Kamu tidak perlu logika khusus untuk "sedang mengetik" — wadah kosong itu sendiri sudah bisa menjadi indikatornya. Untuk keluaran terstruktur, jangan percaya pada instruksi seketat apa pun: model sering membungkus JSON-nya dalam blok kode berpagar, kadang menambahkan kalimat pengantar, dan sesekali mengembalikan sesuatu yang bukan JSON sama sekali. Membersihkan pagar blok kode lalu membungkus jsonDecode dengan try/catch menangani hampir semua kasus; sisanya butuh jalur cadangan.

Variasi 2: Pengurai Struk — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun fitur yang menerima teks struk belanja yang berantakan — ditempel pengguna atau hasil pindaian — dan meminta model mengubahnya menjadi data terstruktur: daftar item dengan harga, subtotal, pajak, dan total. Hasilnya mengisi formulir yang bisa diedit, bukan langsung disimpan.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  Tempel teks struk:                │
│  [___________________________]     │
│           [ Urai dengan AI ]       │
│                                     │
│  Hasil (bisa diedit):              │
│  Kopi Susu        [Rp 20.000]      │
│  Roti Bakar       [Rp 15.000]      │
│  Subtotal         Rp 35.000        │
│  ⚠ Total di struk (Rp 40.000)      │
│    tidak cocok — periksa lagi      │
│              [ Simpan ]            │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Kriteria selesai:

  • Skema keluaran dinyatakan tegas ke model, dan hasilnya divalidasi terhadap skema itu sebelum dipakai.
  • Total yang tidak cocok dengan jumlah itemnya ditandai untuk pengguna, bukan diterima diam-diam.
  • Kegagalan penguraian menampilkan teks aslinya supaya pengguna bisa mengoreksi manual.
  • Hasil AI mengisi formulir yang bisa diedit, dan pengguna harus menyetujui sebelum disimpan.

Petunjuk: Kriteria kedua adalah lapisan pertahanan yang murah dan sangat berguna: model bisa saja salah membaca satu angka, dan penjumlahan sederhana di sisimu langsung menangkapnya. Prinsip yang lebih besar ada di kriteria keempat — konten hasil AI sebaiknya menjadi usulan, bukan keputusan. Pengguna yang tetap memegang kendali akan memaafkan kesalahan sesekali; pengguna yang datanya diubah diam-diam tidak.

Variasi 3: Anggaran Token — ⭐⭐⭐ · 45–60 menit

Tantangan: Tambahkan pengendalian biaya ke asisten yang sudah kamu buat: batas pemakaian harian per pengguna, ringkasan riwayat percakapan yang panjang supaya tidak seluruhnya dikirim ulang, dan tampilan sisa kuota yang jujur.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  Sisa kuota hari ini: 12/50 pesan  │
│  ┌─────────────────────────────┐  │
│  │ 📋 Ringkasan 40 pesan lalu:   │  │
│  │   "Membahas rencana liburan   │  │
│  │    ke Bali bulan depan..."    │  │
│  │ Kamu: Lanjut soal hotelnya    │  │
│  └─────────────────────────────┘  │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Kriteria selesai:

  • Riwayat yang melewati ambang tertentu diringkas menjadi satu pesan ringkasan, dan percakapan tetap terasa berkesinambungan.
  • Batas harian ditegakkan dan tidak bisa dilewati hanya dengan menutup lalu membuka aplikasi.
  • Pengguna melihat sisa kuotanya sebelum kehabisan, bukan setelah ditolak.
  • Kamu bisa memperkirakan biaya satu percakapan panjang, dalam angka.

Petunjuk: Biaya yang sering mengejutkan orang adalah bahwa seluruh riwayat dikirim ulang di setiap permintaan — percakapan lima puluh pesan berarti permintaan kelima puluh membayar untuk empat puluh sembilan pesan sebelumnya. Meringkas riwayat lama menjadi satu paragraf memangkasnya drastis dengan biaya kehilangan detail. Untuk kriteria kedua, batas yang disimpan hanya di perangkat bisa diakali; kalau ini benar-benar penting, penegakannya harus di server.

Variasi 4: Kunci di Balik Server — ⭐⭐⭐⭐ · 60–90 menit

Tantangan: Pindahkan seluruh pemanggilan Gemini dari aplikasi ke Cloud Function, sehingga kunci API tidak pernah ada di perangkat sama sekali. Pertahankan streaming supaya pengalaman penggunanya tidak berubah.

Kriteria selesai:

  • Aplikasi tidak lagi memuat kunci API dalam bentuk apa pun.
  • Streaming tetap bekerja — jawaban tetap mengalir, bukan datang sekaligus.
  • Fungsi menolak permintaan dari pengguna yang belum masuk.
  • Ada pembatasan laju di sisi server supaya satu akun tidak bisa mengirim ratusan permintaan per menit.

Petunjuk: --dart-define sering disalahpahami sebagai cara mengamankan kunci; ia hanya memindahkan kunci dari kode sumber ke berkas build, dan kunci itu tetap ada di dalam aplikasi yang bisa dibongkar siapa pun. Satu-satunya cara kunci benar-benar aman adalah tidak pernah mengirimkannya ke perangkat. Streaming lewat fungsi butuh respons yang mengalir — cari dukungan streaming pada platform fungsi yang kamu pakai, atau pakai koneksi yang tetap terbuka.

Ikhtisar

  • Jangan taruh kunci Gemini di dalam aplikasi untuk produksi — aplikasi bisa dibongkar. Panggil lewat Cloud Function.
  • systemInstruction adalah cara paling andal mengendalikan kepribadian dan batasan asisten — jauh lebih baik daripada menyisipkan instruksi di setiap pesan.
  • ChatSession menyimpan riwayat otomatis, tetapi batasi jumlah pesan yang dikirim — setiap pesan lama ikut dihitung sebagai token input pada setiap permintaan.
  • sendMessageStream membuat jawaban mengalir kata demi kata. Ini perbedaan terbesar antara chatbot yang terasa hidup dan yang terasa lambat.
  • Pola implementasinya: tambahkan wadah kosong ke daftar, lalu isi bertahap sambil memanggil notifyListeners().
  • Tampilkan pesan pengguna segera, jangan menunggu jawaban.
  • Terjemahkan kegagalan API menjadi pesan yang bisa ditindaklanjuti, dan sediakan tombol "Coba lagi" pada jawaban yang gagal.
  • Model tidak menjamin keluaran valid JSON meskipun diminta tegas. Bersihkan blok kode, bungkus try/catch, dan sediakan jalur cadangan.
  • Konten hasil AI sebaiknya mengisi kolom yang bisa diedit, bukan langsung disimpan — pengguna tetap memegang kendali.
  • Tampilkan saran pertanyaan saat percakapan kosong, agar pengguna tahu apa yang bisa ditanyakan.
  • Batasi pemakaian per pengguna di server, atau satu orang bisa menghabiskan kuotamu.

Berikutnya: Bab 35 — Google AdMob.

Transkrip asli

Disintesis dari 4_flutter_ai-chatbot-n-firebase/12_building-an-ai-chatbot.md (9 video: Chatbot UI I & II, Gemini AI Integration, Messages Integration, Chat Image Support I & II, AI Chat History, Loading Chat Window with Previous Messages, Streamline Poll Creation using AI) dan 11_project-branding-and-documentation.md (video 3–4: Chatbot UI Design). Lihat PDF Firebase & AI.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.