Skip to content

Commit 8fb4d36

Browse files
committed
feat: integrate chatbot assistant
1 parent 50d7848 commit 8fb4d36

17 files changed

Lines changed: 820 additions & 64 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import 'dart:convert';
2+
3+
class ChatMessageModel {
4+
final String text;
5+
final bool isUser;
6+
final DateTime timestamp;
7+
8+
ChatMessageModel({
9+
required this.text,
10+
required this.isUser,
11+
DateTime? timestamp,
12+
}) : timestamp = timestamp ?? DateTime.now();
13+
14+
factory ChatMessageModel.fromJson(Map<String, dynamic> json) {
15+
final parsedTimestamp =
16+
DateTime.tryParse(json['timestamp']?.toString() ?? '') ?? DateTime.now();
17+
18+
return ChatMessageModel(
19+
text: json['text']?.toString() ?? '',
20+
isUser: json['isUser'] as bool? ?? true,
21+
timestamp: parsedTimestamp,
22+
);
23+
}
24+
25+
Map<String, dynamic> toJson() {
26+
return {
27+
'text': text,
28+
'isUser': isUser,
29+
'timestamp': timestamp.toIso8601String(),
30+
};
31+
}
32+
33+
static String encodeList(List<ChatMessageModel> messages) {
34+
return jsonEncode(messages.map((message) => message.toJson()).toList());
35+
}
36+
37+
static List<ChatMessageModel> decodeList(String raw) {
38+
final decoded = jsonDecode(raw);
39+
if (decoded is! List) return [];
40+
41+
return decoded
42+
.whereType<Map>()
43+
.map((item) => ChatMessageModel.fromJson(Map<String, dynamic>.from(item)))
44+
.toList();
45+
}
46+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import 'package:flutter/foundation.dart';
2+
import 'package:flutter_dotenv/flutter_dotenv.dart';
3+
import 'package:google_generative_ai/google_generative_ai.dart';
4+
5+
class ChatBotAssistantService {
6+
final String _apiKey = (dotenv.env['GEMINI_API_KEY'] ?? '').trim();
7+
GenerativeModel? _model;
8+
ChatSession? _chatSession;
9+
10+
ChatBotAssistantService() {
11+
if (_apiKey.isEmpty) {
12+
debugPrint("Forget to set GEMINI_API_KEY in .env file");
13+
return;
14+
}
15+
16+
_model = GenerativeModel(
17+
apiKey: _apiKey,
18+
model: 'gemini-2.5-flash',
19+
systemInstruction: Content.system(
20+
'Bạn là một chuyên gia quản lý thời gian và trợ lý năng suất cho ứng dụng Task Management. '
21+
'Nhiệm vụ của bạn là đưa ra lời khuyên ngắn gọn (dưới 100 chữ), thực tế để giúp người dùng '
22+
'hoàn thành công việc. Trả lời bằng tiếng Việt thân thiện, nhiệt tình. '
23+
'Từ chối mọi câu hỏi không liên quan đến công việc hoặc quản lý thời gian.',
24+
),
25+
);
26+
27+
_chatSession = _model!.startChat();
28+
}
29+
30+
Future<String> sendMessage(String userMessage) async {
31+
if (_chatSession == null) {
32+
return 'Chatbot chưa được cấu hình API key. Vui lòng kiểm tra file .env.';
33+
}
34+
35+
try {
36+
final response = await _chatSession!.sendMessage(
37+
Content.text(userMessage),
38+
);
39+
return response.text ?? 'Xin lỗi, trợ lý đang bận xíu. Thử lại sau nhé!';
40+
} catch (e) {
41+
return 'Lỗi kết nối AI: $e';
42+
}
43+
}
44+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:provider/provider.dart';
3+
import 'package:task_management_app/features/chatbot/view/widgets/chat_header.dart';
4+
import 'package:task_management_app/features/chatbot/view/widgets/day_separator.dart';
5+
import 'package:task_management_app/features/chatbot/view/widgets/message_composer.dart';
6+
import 'package:task_management_app/features/chatbot/view/widgets/message_tile.dart';
7+
import 'package:task_management_app/features/chatbot/view/widgets/typing_indicator.dart';
8+
9+
import '../viewmodel/chatbot_viewmodel.dart';
10+
11+
class ChatBotView extends StatelessWidget {
12+
const ChatBotView({super.key, this.userAvatarUrl});
13+
14+
final String? userAvatarUrl;
15+
16+
@override
17+
Widget build(BuildContext context) {
18+
return ChangeNotifierProvider(
19+
create: (_) => ChatBotViewModel(),
20+
child: _ChatBotViewBody(userAvatarUrl: userAvatarUrl),
21+
);
22+
}
23+
}
24+
25+
class _ChatBotViewBody extends StatefulWidget {
26+
const _ChatBotViewBody({this.userAvatarUrl});
27+
28+
final String? userAvatarUrl;
29+
30+
@override
31+
State<_ChatBotViewBody> createState() => _ChatBotViewBodyState();
32+
}
33+
34+
class _ChatBotViewBodyState extends State<_ChatBotViewBody> {
35+
final TextEditingController _controller = TextEditingController();
36+
final ScrollController _scrollController = ScrollController();
37+
38+
void _scrollToBottom() {
39+
WidgetsBinding.instance.addPostFrameCallback((_) {
40+
if (_scrollController.hasClients) {
41+
_scrollController.animateTo(
42+
_scrollController.position.maxScrollExtent,
43+
duration: const Duration(milliseconds: 300),
44+
curve: Curves.easeOut,
45+
);
46+
}
47+
});
48+
}
49+
50+
@override
51+
void dispose() {
52+
_controller.dispose();
53+
_scrollController.dispose();
54+
super.dispose();
55+
}
56+
57+
Future<void> _sendMessage(ChatBotViewModel viewModel) async {
58+
final text = _controller.text.trim();
59+
if (text.isEmpty || viewModel.isLoading) return;
60+
61+
_controller.clear();
62+
_scrollToBottom();
63+
64+
await viewModel.sendMessage(text);
65+
if (!mounted) return;
66+
67+
_scrollToBottom();
68+
}
69+
70+
@override
71+
Widget build(BuildContext context) {
72+
final theme = Theme.of(context);
73+
final scheme = theme.colorScheme;
74+
75+
return Scaffold(
76+
backgroundColor: theme.scaffoldBackgroundColor,
77+
body: SafeArea(
78+
child: Column(
79+
children: [
80+
const ChatHeader(),
81+
Divider(height: 1, color: scheme.outline.withValues(alpha: 0.4)),
82+
Expanded(
83+
child: Consumer<ChatBotViewModel>(
84+
builder: (context, viewModel, _) {
85+
return ListView.builder(
86+
controller: _scrollController,
87+
padding: const EdgeInsets.symmetric(
88+
horizontal: 16,
89+
vertical: 14,
90+
),
91+
itemCount: viewModel.messages.length + 1,
92+
itemBuilder: (context, index) {
93+
if (index == 0) {
94+
return const Padding(
95+
padding: EdgeInsets.only(bottom: 16),
96+
child: DaySeparator(label: 'Today'),
97+
);
98+
}
99+
100+
final message = viewModel.messages[index - 1];
101+
return MessageTile(
102+
message: message,
103+
userAvatarUrl: widget.userAvatarUrl,
104+
);
105+
},
106+
);
107+
},
108+
),
109+
),
110+
Consumer<ChatBotViewModel>(
111+
builder: (context, viewModel, _) {
112+
if (!viewModel.isLoading) return const SizedBox.shrink();
113+
return const Padding(
114+
padding: EdgeInsets.fromLTRB(16, 0, 16, 12),
115+
child: TypingIndicator(),
116+
);
117+
},
118+
),
119+
Consumer<ChatBotViewModel>(
120+
builder: (context, viewModel, _) {
121+
return MessageComposer(
122+
controller: _controller,
123+
isSending: viewModel.isLoading,
124+
onSend: () => _sendMessage(viewModel),
125+
);
126+
},
127+
),
128+
],
129+
),
130+
),
131+
);
132+
}
133+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import 'package:flutter/material.dart';
2+
3+
class BotAvatar extends StatelessWidget {
4+
const BotAvatar({super.key, required this.size});
5+
6+
final double size;
7+
8+
@override
9+
Widget build(BuildContext context) {
10+
final scheme = Theme.of(context).colorScheme;
11+
12+
return Container(
13+
width: size,
14+
height: size,
15+
decoration: BoxDecoration(
16+
shape: BoxShape.circle,
17+
color: scheme.surfaceContainerHighest,
18+
),
19+
alignment: Alignment.center,
20+
child: Icon(
21+
Icons.smart_toy_rounded,
22+
size: size * 0.58,
23+
color: scheme.primary,
24+
),
25+
);
26+
}
27+
}
28+
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import 'package:flutter/material.dart';
2+
3+
import 'bot_avatar.dart';
4+
5+
class ChatHeader extends StatelessWidget {
6+
const ChatHeader({super.key});
7+
8+
@override
9+
Widget build(BuildContext context) {
10+
final theme = Theme.of(context);
11+
final scheme = theme.colorScheme;
12+
13+
return Padding(
14+
padding: const EdgeInsets.fromLTRB(16, 8, 16, 10),
15+
child: Row(
16+
children: [
17+
const BotAvatar(size: 48),
18+
const SizedBox(width: 12),
19+
Expanded(
20+
child: Column(
21+
crossAxisAlignment: CrossAxisAlignment.start,
22+
children: [
23+
Text(
24+
'TaskBot',
25+
style: theme.textTheme.headlineSmall?.copyWith(
26+
color: scheme.primary,
27+
fontWeight: FontWeight.w800,
28+
),
29+
),
30+
Text(
31+
'ONLINE AI ASSISTANT',
32+
style: theme.textTheme.labelLarge?.copyWith(
33+
color: scheme.tertiary,
34+
letterSpacing: 0.6,
35+
fontWeight: FontWeight.w600,
36+
),
37+
),
38+
],
39+
),
40+
),
41+
IconButton(
42+
onPressed: () {},
43+
icon: Icon(Icons.settings, color: scheme.onSurfaceVariant),
44+
),
45+
],
46+
),
47+
);
48+
}
49+
}
50+
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import 'package:flutter/material.dart';
2+
3+
class DaySeparator extends StatelessWidget {
4+
const DaySeparator({super.key, required this.label});
5+
6+
final String label;
7+
8+
@override
9+
Widget build(BuildContext context) {
10+
final scheme = Theme.of(context).colorScheme;
11+
return Center(
12+
child: Container(
13+
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
14+
decoration: BoxDecoration(
15+
color: scheme.surfaceContainerHighest.withValues(alpha: 0.7),
16+
borderRadius: BorderRadius.circular(20),
17+
),
18+
child: Text(
19+
label,
20+
style: TextStyle(
21+
color: scheme.onSurfaceVariant,
22+
fontWeight: FontWeight.w600,
23+
),
24+
),
25+
),
26+
);
27+
}
28+
}
29+

0 commit comments

Comments
 (0)