Chat dengan Ai

Struktur Proyek

Pertama, sesuaikan struktur folder dan file berikut di dalam direktori lib/ Anda:

lib/
├── main.dart
|
├── models/
│   └── chat_message.dart
|
├── screens/
│   └── chat_screen.dart
|
├── services/
│   └── gemini_service.dart
|
└── widgets/
    ├── message_bubble.dart
    └── message_input_bar.dart

Langkah 1: Persiapan (Sama seperti sebelumnya)

  1. Dapatkan Gemini API Key dari Google AI Studio.

  2. Buat file .env di root proyek dan isi dengan:

    GEMINI_API_KEY=API_KEY_ANDA
  3. Daftarkan .env di pubspec.yaml di bawah assets:YAML

    flutter:
      assets:
        - .env
  4. Tambahkan dependensi di terminal:Bash

    flutter pub add google_generative_ai flutter_dotenv flutter_markdown uuid

    (Kita menambahkan uuid untuk memberikan ID unik pada setiap pesan, ini adalah praktik yang baik).


Langkah 2: Tulis Kode untuk Setiap File

Sekarang, mari kita isi setiap file dengan kodenya masing-masing.

📁 lib/models/chat_message.dart

File ini mendefinisikan struktur data untuk sebuah pesan chat.

Dart

// lib/models/chat_message.dart

class ChatMessage {
  final String id;
  final String text;
  final bool isFromUser;
  final bool isTyping; // Untuk menampilkan indikator loading
  final bool isError;  // Untuk menandai pesan error

  ChatMessage({
    required this.id,
    required this.text,
    required this.isFromUser,
    this.isTyping = false,
    this.isError = false,
  });
}

Tujuan: Memisahkan model data dari UI dan logika, membuat kode lebih mudah dibaca dan dikelola.


📁 lib/services/gemini_service.dart

File ini bertanggung jawab untuk semua interaksi dengan Gemini API. File ini tidak tahu menahu tentang UI (Flutter).

Dart

// lib/services/gemini_service.dart

import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:google_generative_ai/google_generative_ai.dart';

class GeminiService {
  late final GenerativeModel _model;
  late final ChatSession _chat;

  // Inisialisasi model dan sesi chat
  GeminiService() {
    final apiKey = dotenv.env['GEMINI_API_KEY'];
    if (apiKey == null) {
      throw Exception("GEMINI_API_KEY not found in .env file");
    }

    _model = GenerativeModel(
      model: 'gemini-1.5-flash',
      apiKey: apiKey,
    );
    _chat = _model.startChat();
  }

  /// Mengirim pesan ke Gemini dan mengembalikan respons teks
  Future<String> sendMessage(String prompt) async {
    try {
      final response = await _chat.sendMessage(Content.text(prompt));
      final text = response.text;

      if (text == null) {
        throw Exception("Received null response from Gemini.");
      }
      return text;
    } catch (e) {
      // Melempar kembali error untuk ditangani oleh UI
      print("Error sending message: $e");
      rethrow;
    }
  }
}

Tujuan: Mengisolasi logika API. Jika Anda ingin beralih ke layanan AI lain, Anda hanya perlu mengubah file ini.


📁 lib/widgets/message_bubble.dart

Widget ini hanya untuk menampilkan satu gelembung pesan.

Dart

// lib/widgets/message_bubble.dart

import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:gemini_chat_app/models/chat_message.dart';

class MessageBubble extends StatelessWidget {
  final ChatMessage message;

  const MessageBubble({super.key, required this.message});

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

    // Menampilkan indikator loading jika pesan adalah 'typing'
    if (message.isTyping) {
      return Align(
        alignment: Alignment.centerLeft,
        child: Container(
          margin: const EdgeInsets.only(bottom: 8, left: 15, right: 15),
          padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 15),
          decoration: BoxDecoration(
            color: colors.surfaceVariant,
            borderRadius: BorderRadius.circular(18),
          ),
          child: const SizedBox(
            width: 25,
            height: 25,
            child: CircularProgressIndicator(strokeWidth: 3),
          ),
        ),
      );
    }
    
    // Menentukan warna dan alignment berdasarkan pengirim atau status error
    Color bubbleColor;
    if (message.isError) {
      bubbleColor = colors.errorContainer;
    } else if (message.isFromUser) {
      bubbleColor = colors.primaryContainer;
    } else {
      bubbleColor = colors.surfaceVariant;
    }


    return Align(
      alignment: message.isFromUser ? Alignment.centerRight : Alignment.centerLeft,
      child: Container(
        margin: const EdgeInsets.only(bottom: 8, left: 15, right: 15),
        padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 15),
        decoration: BoxDecoration(
          color: bubbleColor,
          borderRadius: BorderRadius.circular(18),
        ),
        child: MarkdownBody(
          data: message.text,
          selectable: true,
        ),
      ),
    );
  }
}

Tujuan: Membuat widget yang dapat digunakan kembali untuk tampilan pesan, membuat kode layar utama (chat_screen.dart) lebih bersih.


📁 lib/widgets/message_input_bar.dart

Widget ini meng-enkapsulasi TextField dan tombol kirim.

Dart

// lib/widgets/message_input_bar.dart

import 'package:flutter/material.dart';

class MessageInputBar extends StatelessWidget {
  final TextEditingController controller;
  final bool isLoading;
  final VoidCallback onSend;

  const MessageInputBar({
    super.key,
    required this.controller,
    required this.isLoading,
    required this.onSend,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 15),
      child: Row(
        children: [
          Expanded(
            child: TextField(
              controller: controller,
              autofocus: true,
              decoration: InputDecoration(
                contentPadding: const EdgeInsets.all(15),
                hintText: 'Ketik pesan...',
                border: OutlineInputBorder(
                  borderRadius: const BorderRadius.all(Radius.circular(14)),
                  borderSide: BorderSide(color: Theme.of(context).colorScheme.secondary),
                ),
                focusedBorder: OutlineInputBorder(
                  borderRadius: const BorderRadius.all(Radius.circular(14)),
                  borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
                ),
              ),
              onSubmitted: (_) => isLoading ? null : onSend(),
            ),
          ),
          const SizedBox.square(dimension: 15),
          IconButton(
            icon: Icon(Icons.send, color: Theme.of(context).colorScheme.primary),
            onPressed: isLoading ? null : onSend,
          ),
        ],
      ),
    );
  }
}

Tujuan: Memisahkan logika input dari layout utama layar, membuatnya lebih mudah untuk diubah atau digunakan kembali di tempat lain.


📁 lib/screens/chat_screen.dart

File ini menjadi "konduktor" yang mengatur state dan menyatukan semua widget menjadi satu layar fungsional.

Dart

// lib/screens/chat_screen.dart

import 'package:flutter/material.dart';
import 'package:gemini_chat_app/models/chat_message.dart';
import 'package:gemini_chat_app/services/gemini_service.dart';
import 'package:gemini_chat_app/widgets/message_bubble.dart';
import 'package:gemini_chat_app/widgets/message_input_bar.dart';
import 'package:uuid/uuid.dart';

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

  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  final GeminiService _geminiService = GeminiService();
  final TextEditingController _textController = TextEditingController();
  final ScrollController _scrollController = ScrollController();
  final List<ChatMessage> _messages = [];
  bool _isLoading = false;
  final Uuid _uuid = const Uuid();

  void _scrollDown() {
    WidgetsBinding.instance.addPostFrameCallback(
      (_) => _scrollController.animateTo(
        _scrollController.position.maxScrollExtent,
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeOut,
      ),
    );
  }

  Future<void> _sendMessage() async {
    if (_textController.text.isEmpty) return;

    final userMessageText = _textController.text;
    _textController.clear();
    FocusScope.of(context).unfocus(); // Tutup keyboard

    setState(() {
      _isLoading = true;
      // Tambahkan pesan pengguna
      _messages.add(ChatMessage(
        id: _uuid.v4(),
        text: userMessageText,
        isFromUser: true,
      ));
      _scrollDown();
      // Tambahkan indikator 'typing' dari AI
      _messages.add(ChatMessage(
        id: _uuid.v4(),
        text: '...',
        isFromUser: false,
        isTyping: true,
      ));
      _scrollDown();
    });

    try {
      final responseText = await _geminiService.sendMessage(userMessageText);
      setState(() {
        // Hapus indikator 'typing'
        _messages.removeWhere((msg) => msg.isTyping);
        // Tambahkan respons AI
        _messages.add(ChatMessage(
          id: _uuid.v4(),
          text: responseText,
          isFromUser: false,
        ));
      });
    } catch (e) {
      setState(() {
        // Hapus indikator 'typing'
         _messages.removeWhere((msg) => msg.isTyping);
        // Tambahkan pesan error
        _messages.add(ChatMessage(
          id: _uuid.v4(),
          text: 'Maaf, terjadi kesalahan. Coba lagi.',
          isFromUser: false,
          isError: true,
        ));
      });
    } finally {
      setState(() {
        _isLoading = false;
      });
      _scrollDown();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Gemini Clean Arch'),
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              itemCount: _messages.length,
              itemBuilder: (context, index) {
                final message = _messages[index];
                return MessageBubble(message: message);
              },
            ),
          ),
          MessageInputBar(
            controller: _textController,
            isLoading: _isLoading,
            onSend: _sendMessage,
          ),
        ],
      ),
    );
  }
}

Tujuan: Mengelola state aplikasi (daftar pesan, status loading) dan menyusun UI dari berbagai widget terpisah.


📁 lib/main.dart

File main.dart sekarang menjadi sangat sederhana. Tugasnya hanya menginisialisasi aplikasi dan menunjuk ke layar utama.

Dart

// lib/main.dart

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

void main() async {
  // Pastikan binding Flutter siap sebelum memuat dotenv
  WidgetsFlutterBinding.ensureInitialized();
  // Muat environment variables dari file .env
  await dotenv.load(fileName: ".env");
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Gemini Chat App',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.deepPurple,
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
      ),
      home: const ChatScreen(),
      debugShowCheckedModeBanner: false,
    );
  }
}

Tujuan: Sebagai titik masuk (entry point) aplikasi yang bersih dan ringkas.

Langkah 3: Jalankan Aplikasi

link github

https://github.com/triyono777/flutter_supabase_notes_app/tree/fitur-ai

Last updated

Was this helpful?