지난 Step1 에서는 HTTP REST API, JSON, Gemini API, Isar Database 에 대하여 개념을 잡아 보았다.
이번 Step2 에서는 구현 코드와 함께 필요한 내용을 코드 내에 주석으로 기록해 봅니다.
코드로 들어가기에 앞서 ‘Isar’ 데이터베이스 관련 안드로이드 Gradle 이슈가 있었는데 해당 내용은 아래 링크를 참조하기 바랍니다.
https://totheeden.ddnsgeek.com/how-to-automatically-add-the-namespace/
Gemini API의 키를 발급 받는 곳은 아래 링크로 가시면 됩니다.
구현 코드
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개의 생각