Flutter AI Chat Example Step 2: HTTP REST API, JSON, Gemini API, Isar DB

지난 Step1 에서는 HTTP REST API, JSON, Gemini API, Isar Database 에 대하여 개념을 잡아 보았다.

이번 Step2 에서는 구현 코드와 함께 필요한 내용을 코드 내에 주석으로 기록해 봅니다.

코드로 들어가기에 앞서 ‘Isar’ 데이터베이스 관련 안드로이드 Gradle 이슈가 있었는데 해당 내용은 아래 링크를 참조하기 바랍니다.

https://totheeden.ddnsgeek.com/how-to-automatically-add-the-namespace/

Gemini API의 키를 발급 받는 곳은 아래 링크로 가시면 됩니다.

https://ai.google.dev

구현 코드

pubspec.yaml

...
dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.8
  isar: ^3.1.0+1
  isar_flutter_libs: ^3.1.0+1
  path_provider: ^2.1.4
  google_generative_ai: ^0.4.6
  get_it: ^8.0.1
...
dev_dependencies:
  flutter_test:
    sdk: flutter

  # The "flutter_lints" package below contains a set of recommended lints to
  # encourage good coding practices. The lint set provided by the package is
  # activated in the `analysis_options.yaml` file located at the root of your
  # package. See that file for information about deactivating specific lint
  # rules and activating additional ones.
  flutter_lints: ^5.0.0
  build_runner: ^2.4.13
  isar_generator: ^3.1.0+1
...
  # To add assets to your application, add an assets section, like this:
  # assets:
  #   - images/a_dot_burr.jpeg
  #   - images/a_dot_ham.jpeg
  assets:
    - asset/img/

Logo Widget

상단 로고 이미지 위젯과 앱 설명 구현

component/logo.dart

import 'package:flutter/material.dart';

class Logo extends StatelessWidget {
  final double width;
  final double height;

  const Logo({super.key, this.width = 200, this.height = 200});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Image.asset('asset/img/logo.png', width: width, height: height),
        SizedBox(height: 32.0),
        Text(
          '안녕하세요',
          style: TextStyle(
            color: Colors.black,
            fontSize: 16.0,
            fontStyle: FontStyle.italic,
          ),
        ),
      ],
    );
  }
}

PointNotification Widget

Gemini에게 메시지를 보낼 때마다 포인트를 적립하는데 이를 표시해 줄 위젯

component/point_notification.dart

import 'package:flutter/material.dart';

class PointNotification extends StatelessWidget {
  final int point;

  const PointNotification({super.key, required this.point});

  @override
  Widget build(BuildContext context) {
    return Text(
      '$point 포인트가 적립되었습니다!',
      style: TextStyle(color: Colors.blueAccent, fontStyle: FontStyle.italic),
    );
  }
}

Message Widget

채팅 버블 위젯으로 내가 보낸 메시지는 오른쪽, Gemini의 메시지는 왼쪽에 정렬합니다.

component/message.dart

import 'package:flutter/material.dart';
import 'package:gemini_chat/component/point_notification.dart';

class Message extends StatelessWidget {
  final bool alignLeft;
  final String message;
  final int? point;

  const Message({
    super.key,
    this.alignLeft = true,
    required this.message,
    this.point,
  });

  @override
  Widget build(BuildContext context) {
    final alignment = alignLeft ? Alignment.centerLeft : Alignment.centerRight;
    final bgColor = alignLeft ? Color(0xFFF4F4F4) : Colors.white;
    final border = alignLeft ? Color(0xFFE7E7E7) : Colors.black12;

    return Column(
      children: [
        Align(
          alignment: alignment,
          child: Container(
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(32.0),
              color: bgColor,
              border: Border.all(color: border, width: 1.0),
            ),
            child: Padding(
              padding: const EdgeInsets.symmetric(
                vertical: 8.0,
                horizontal: 16.0,
              ),
              child: Text(
                message,
                style: TextStyle(color: Colors.black, fontSize: 16),
              ),
            ),
          ),
        ),
        if (point != null)
          Align(
            alignment: alignment,
            child: PointNotification(point: point!),
          ),
      ],
    );
  }
}

DateDivider Widget

이전 채팅과 현재 채팅 메시지의 생성 날짜가 다를 때 화면에 구분해 주기 위해 날짜를 표시하는 위젯입니다.

component/date_divider.dart

import 'package:flutter/material.dart';

class DateDivider extends StatelessWidget {
  final DateTime date;

  const DateDivider({super.key, required this.date});

  @override
  Widget build(BuildContext context) {
    return Text(
      '${date.year}년 ${date.month}월 ${date.day}일',
      style: TextStyle(color: Colors.black54, fontSize: 12),
      textAlign: TextAlign.center,
    );
  }
}

ChatTextField Widget

채팅을 입력하는 위젯입니다.

component/chat_text_field.dart

import 'package:flutter/material.dart';

class ChatTextField extends StatelessWidget {
  final TextEditingController controller;
  final VoidCallback onSend;
  final String? error;
  final bool loading;

  const ChatTextField({
    Key? key,
    required this.controller,
    required this.onSend,
    this.error,
    this.loading = false,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: controller,
      cursorColor: Colors.blueAccent,
      textAlignVertical: TextAlignVertical.center,
      minLines: 1,
      maxLines: 5,
      decoration: InputDecoration(
        errorText: error,
        suffixIcon: IconButton(
            onPressed: loading ? null : onSend,
            icon: Icon(
              Icons.send_outlined,
              color: loading ? Colors.grey : Colors.blueAccent,
            ),
        ),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(32.0),
        ),
        focusedBorder: OutlineInputBorder(
          borderRadius: BorderRadius.circular(32.0),
          borderSide: BorderSide(
            color: Colors.blueAccent,
            width: 2.0,
          ),
        ),
        hintText: 'Type a message',
      ),
    );
  }
}

Message Model

Gemini와 사용자 간의 채팅을 위한 메시지 규격 모델입니다.

model/message_model.dart

import 'package:isar/isar.dart';
part 'message_model.g.dart';

@collection
class MessageModel {
  Id id = Isar.autoIncrement;
  bool isMine;
  String message;
  int? point;
  DateTime date;

  MessageModel({
    required this.isMine,
    required this.message,
    required this.date,
    this.id = Isar.autoIncrement,
    this.point,
  });
}

HomeScreen

screen/home_screen.dart

import 'package:flutter/material.dart';
import 'package:gemini_chat/component/chat_text_field.dart';
import 'package:gemini_chat/component/date_divider.dart';
import 'package:gemini_chat/model/message_model.dart';

import '../component/logo.dart';
import '../component/message.dart';

import 'package:get_it/get_it.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:isar/isar.dart';

final sampleDate = [
  MessageModel(
    id: 1,
    isMine: true,
    message: '오늘 저녁 메뉴 추천해줘!',
    point: 1,
    date: DateTime(2024, 11, 23),
  ),
  MessageModel(
    id: 2,
    isMine: false,
    message: '칼칼한 김치찜은 어때요!?',
    point: null,
    date: DateTime(2024, 11, 23),
  ),
];

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

  @override
  State<StatefulWidget> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  final TextEditingController controller = TextEditingController();
  final ScrollController scrollController = ScrollController();

  bool isRunning = false;
  String? error;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: SafeArea(
        child: Column(
          children: [
            Expanded(
              child: StreamBuilder<List<MessageModel>>(
                stream: GetIt.I<Isar>().messageModels.where().watch(
                  fireImmediately: true,
                ),
                builder: (context, snapshot) {
                  final messages = snapshot.data ?? [];

                  WidgetsBinding.instance.addPostFrameCallback((_) async => scrollToBottom());

                  return buildMessageList(messages);
                },
              ),
            ),
            Padding(
              padding: const EdgeInsets.symmetric(
                horizontal: 8.0,
                vertical: 32.0,
              ),
              child: ChatTextField(
                error: error,
                loading: isRunning,
                onSend: handleSendMessage,
                controller: controller,
              ),
            ),
          ],
        ),
      ),
    );
  }

  void scrollToBottom() {
    if (scrollController.position.pixels != scrollController.position.maxScrollExtent) {
      scrollController.animateTo(
        scrollController.position.maxScrollExtent,
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeOut,
      );
    }
  }

  handleSendMessage() async {
    if (controller.text.isEmpty) {
      setState(() {
        error = 'Input Message!';
      });
      return;
    }

    int? currentModelMessageId;
    int? currentUserMessageId;
    final isar = GetIt.I<Isar>();
    final currentPrompt = controller.text;

    try {
      setState(() {
        isRunning = true;
      });

      controller.clear();

      final myMessageCount = await isar.messageModels
          .filter()
          .isMineEqualTo(true)
          .count();

      currentUserMessageId = await isar.writeTxn(() async {
        return await isar.messageModels.put(
          MessageModel(
            isMine: true,
            message: currentPrompt,
            point: myMessageCount + 1,
            date: DateTime.now(),
          ),
        );
      });

      // final contextMessages = await isar.messageModels
      //     .where()
      //     .limit(5)
      //     .findAll();
      final contextMessages = await isar.messageModels
          .where()
          .findAll();

      final List<Content> promptContext = contextMessages
          .map(
            (e) => Content(e.isMine ? 'user' : 'model', [TextPart(e.message)]),
          )
          .toList();

      final model = GenerativeModel(
        model: 'gemini-2.5-flash',
        apiKey: '${API_KEY}',
        systemInstruction: Content.system('착하고 친절한 친구가 되어줘.'),
      );

      String message = '';

      model
          .generateContentStream(promptContext)
          .listen(
            (event) async {
              if (event.text != null) {
                message += event.text!;
              }

              final MessageModel model = MessageModel(
                isMine: false,
                message: message,
                date: DateTime.now(),
              );

              if (currentModelMessageId != null) {
                model.id = currentModelMessageId!;
              }

              currentModelMessageId = await isar.writeTxn<int>(
                () => isar.messageModels.put(model),
              );
            },
            onDone: () => setState(() {
              isRunning = false;
            }),
            onError: (e) async {
              await isar.writeTxn(() async {
                return isar.messageModels.delete(currentUserMessageId!);
              });

              setState(() {
                error = e.toString();
                isRunning = false;
              });
            },
          );
    } catch (e) {
      ScaffoldMessenger.of(
        context,
      ).showSnackBar(SnackBar(content: Text(e.toString())));
    }
  }

  Widget buildLogo() {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16.0),
      child: const Padding(
        padding: EdgeInsets.only(bottom: 60.0),
        child: Logo(),
      ),
    );
  }

  Widget buildMessageList(List<MessageModel> messages) {
    return ListView.separated(
      controller: scrollController,
      itemBuilder: (context, index) => index == 0
          ? buildLogo()
          : buildMessageItem(
              message: messages[index - 1],
              prevMessage: index > 1 ? messages[index - 2] : null,
              index: index - 1,
            ),
      separatorBuilder: (_, __) => const SizedBox(height: 16.0),
      itemCount: messages.length + 1,
    );
  }

  Widget buildMessageItem({
    MessageModel? prevMessage,
    required MessageModel message,
    required int index,
  }) {
    final isMine = message.isMine;
    final shouldDrawDatedivider =
        prevMessage == null || shouldDrawDate(prevMessage.date, message.date);

    return Column(
      children: [
        if (shouldDrawDatedivider)
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 16.0),
            child: DateDivider(date: message.date),
          ),
        Padding(
          padding: EdgeInsets.only(
            left: isMine ? 64.0 : 16.0,
            right: isMine ? 16.0 : 64.0,
          ),
          child: Message(
            alignLeft: !isMine,
            message: message.message.trim(),
            point: message.point,
          ),
        ),
      ],
    );
  }

  bool shouldDrawDate(DateTime date1, DateTime date2) {
    return getStringDate(date1) != getStringDate(date2);
  }

  String getStringDate(DateTime date) {
    return '${date.year}년 ${date.month}월, ${date.day}일';
  }
}

Flutter Main

main.dart


import 'package:flutter/material.dart';
import 'package:gemini_chat/screen/home_screen.dart';
import 'package:get_it/get_it.dart';
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
import 'package:gemini_chat/model/message_model.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final dir = await getApplicationDocumentsDirectory();

  final isar = await Isar.open(
    [MessageModelSchema],
    directory: dir.path,
  );

  GetIt.I.registerSingleton<Isar>(isar);

  runApp(
    MaterialApp(
      home: HomeScreen(),
    ),
  );
}


Reference

https://heavenly.tistory.com/entry/플러터Flutter-Android-Gradle-8-버전의-요구사항인-namespace를-자동으로-추가하는-방법

“Flutter AI Chat Example Step 2: HTTP REST API, JSON, Gemini API, Isar DB”에 대한 1개의 생각

댓글 남기기