Skip to content

Commit c4b702b

Browse files
authored
Feature/user profile (#37)
* feat(UserProfile): build screen UserProfile # Conflicts: # src/lib/features/main/view/screens/main_screen.dart * feat: switch dark/light theme * fix: black color theme * fix: black theme in statistics screen * feat: add dark theme to auth screen * feat: apply dark theme for bottom navigation bar * feat(RPC): update RPC to get data for heatmap * feat(RPC): update new RPC to get data for heatmap * feat: integrate chatbot assistant * feat(chatbot): integrate create task, answer question for chatbot * feat: remove mock data and get data tags and categories from supabase
1 parent c0755d9 commit c4b702b

38 files changed

Lines changed: 1995 additions & 626 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import 'package:flutter/material.dart';
2+
3+
extension AdaptiveColorExtension on Color {
4+
Color toAdaptiveColor(BuildContext context) {
5+
final isDark = Theme.of(context).brightness == Brightness.dark;
6+
if (!isDark) return this;
7+
8+
return Color.lerp(this, Colors.white, 0.4) ?? this;
9+
}
10+
}
11+
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import 'package:flutter/material.dart';
2+
3+
class CategoryModel {
4+
final int id;
5+
final String name;
6+
final String colorCode;
7+
final String profileId;
8+
9+
const CategoryModel({
10+
required this.id,
11+
required this.name,
12+
required this.colorCode,
13+
required this.profileId,
14+
});
15+
16+
Color get color => _parseHexColor(colorCode);
17+
18+
factory CategoryModel.fromJson(Map<String, dynamic> json) {
19+
return CategoryModel(
20+
id: (json['id'] as num?)?.toInt() ?? 0,
21+
name: json['name']?.toString() ?? '',
22+
colorCode: json['color_code']?.toString() ?? '#5A8DF3',
23+
profileId: json['profile_id']?.toString() ?? '',
24+
);
25+
}
26+
27+
Map<String, dynamic> toJson() {
28+
return {
29+
'id': id,
30+
'name': name,
31+
'color_code': colorCode,
32+
'profile_id': profileId,
33+
};
34+
}
35+
36+
static Color _parseHexColor(String value) {
37+
var hex = value.trim().replaceFirst('#', '');
38+
if (hex.length == 6) {
39+
hex = 'FF$hex';
40+
}
41+
if (hex.length != 8) {
42+
return const Color(0xFF5A8DF3);
43+
}
44+
45+
final parsed = int.tryParse(hex, radix: 16);
46+
if (parsed == null) {
47+
return const Color(0xFF5A8DF3);
48+
}
49+
return Color(parsed);
50+
}
51+
}
52+
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import 'package:supabase_flutter/supabase_flutter.dart';
2+
3+
import '../model/category_model.dart';
4+
5+
class CategoryRepository {
6+
final SupabaseClient _client;
7+
8+
CategoryRepository({SupabaseClient? client})
9+
: _client = client ?? Supabase.instance.client;
10+
11+
Future<List<CategoryModel>> fetchCategories() async {
12+
final user = _client.auth.currentUser;
13+
if (user == null) return [];
14+
15+
final rows = await _client
16+
.from('category')
17+
.select('id, name, color_code, profile_id')
18+
.eq('profile_id', user.id)
19+
.order('name');
20+
21+
return (rows as List<dynamic>)
22+
.map((e) => CategoryModel.fromJson(Map<String, dynamic>.from(e)))
23+
.toList();
24+
}
25+
}
26+
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:task_management_app/core/utils/adaptive_color_extension.dart';
3+
4+
import '../../model/category_model.dart';
5+
6+
class CategoryChoiceChips extends StatelessWidget {
7+
const CategoryChoiceChips({
8+
super.key,
9+
required this.categories,
10+
required this.selectedCategoryId,
11+
required this.onSelected,
12+
});
13+
14+
final List<CategoryModel> categories;
15+
final int? selectedCategoryId;
16+
final ValueChanged<CategoryModel> onSelected;
17+
18+
@override
19+
Widget build(BuildContext context) {
20+
return SizedBox(
21+
height: 40,
22+
child: ListView.builder(
23+
scrollDirection: Axis.horizontal,
24+
itemCount: categories.length,
25+
itemBuilder: (context, index) {
26+
final category = categories[index];
27+
final adaptiveColor = category.color.toAdaptiveColor(context);
28+
final isSelected = category.id == selectedCategoryId;
29+
return Padding(
30+
padding: const EdgeInsets.only(right: 10),
31+
child: ChoiceChip(
32+
label: Text(category.name),
33+
selected: isSelected,
34+
onSelected: (selected) {
35+
if (selected) onSelected(category);
36+
},
37+
backgroundColor: adaptiveColor.withValues(alpha: 0.15),
38+
selectedColor: adaptiveColor,
39+
labelStyle: TextStyle(
40+
color: isSelected ? Colors.white : adaptiveColor,
41+
fontSize: 14,
42+
),
43+
shape: RoundedRectangleBorder(
44+
borderRadius: BorderRadius.circular(10),
45+
side: BorderSide(
46+
color: adaptiveColor.withValues(alpha: 0.4),
47+
width: 1,
48+
),
49+
),
50+
showCheckmark: false,
51+
),
52+
);
53+
},
54+
),
55+
);
56+
}
57+
}
58+
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import 'package:flutter/foundation.dart';
2+
3+
import '../model/category_model.dart';
4+
import '../repository/category_repository.dart';
5+
6+
class CategoryViewModel extends ChangeNotifier {
7+
final CategoryRepository _repository;
8+
9+
CategoryViewModel({CategoryRepository? repository})
10+
: _repository = repository ?? CategoryRepository();
11+
12+
final List<CategoryModel> _categories = [];
13+
14+
List<CategoryModel> get categories => List.unmodifiable(_categories);
15+
16+
bool _isLoading = false;
17+
bool get isLoading => _isLoading;
18+
19+
String? _error;
20+
String? get error => _error;
21+
22+
Future<void> loadCategories() async {
23+
_isLoading = true;
24+
_error = null;
25+
notifyListeners();
26+
27+
try {
28+
final data = await _repository.fetchCategories();
29+
_categories
30+
..clear()
31+
..addAll(data);
32+
} catch (e) {
33+
_error = e.toString();
34+
_categories.clear();
35+
} finally {
36+
_isLoading = false;
37+
notifyListeners();
38+
}
39+
}
40+
}
41+
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: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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+
import 'package:supabase_flutter/supabase_flutter.dart';
5+
6+
class ChatBotAssistantService {
7+
final String _apiKey = (dotenv.env['GEMINI_API_KEY'] ?? '').trim();
8+
GenerativeModel? _model;
9+
ChatSession? _chatSession;
10+
11+
ChatBotAssistantService() {
12+
if (_apiKey.isEmpty) {
13+
debugPrint("Forget to set GEMINI_API_KEY in .env file");
14+
return;
15+
}
16+
17+
_model = GenerativeModel(
18+
apiKey: _apiKey,
19+
model: 'gemini-2.5-flash',
20+
tools: [
21+
Tool(
22+
functionDeclarations: [
23+
FunctionDeclaration(
24+
'create_task_full',
25+
'Tạo một công việc mới. Hãy tự động trích xuất tên công việc, suy luận độ ưu tiên (1-Thấp, 2-Trung bình, 3-Cao) và các thẻ (tags) dựa trên câu nói của người dùng.',
26+
Schema(
27+
SchemaType.object,
28+
properties: {
29+
'title': Schema(
30+
SchemaType.string,
31+
description: 'Tên công việc cần làm',
32+
),
33+
'priority': Schema(
34+
SchemaType.integer,
35+
description:
36+
'Độ ưu tiên: 1 (Thấp), 2 (Trung bình), 3 (Cao). Nếu người dùng không nói rõ, mặc định là 1.',
37+
),
38+
'tags': Schema(
39+
SchemaType.array,
40+
items: Schema(SchemaType.string),
41+
description:
42+
'Danh sách các thẻ phân loại (ví dụ: ["Học tập", "Gấp", "Backend"]). Gửi mảng rỗng [] nếu không có.',
43+
),
44+
},
45+
requiredProperties: ['title', 'priority', 'tags'],
46+
),
47+
),
48+
],
49+
),
50+
],
51+
systemInstruction: Content.system(
52+
'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. '
53+
'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 '
54+
'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. '
55+
'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.',
56+
),
57+
);
58+
59+
_chatSession = _model!.startChat();
60+
}
61+
62+
Future<String> sendMessage(String userMessage) async {
63+
if (_chatSession == null) {
64+
return 'Chatbot chưa được cấu hình API key. Vui lòng kiểm tra file .env.';
65+
}
66+
67+
try {
68+
final response = await _chatSession!.sendMessage(
69+
Content.text(userMessage),
70+
);
71+
72+
if (response.functionCalls.isNotEmpty) {
73+
final functionCall = response.functionCalls.first;
74+
if (functionCall.name == 'create_task_full') {
75+
final args = functionCall.args;
76+
final title = args['title'] as String;
77+
final priority = (args['priority'] as num?)?.toInt() ?? 1;
78+
final rawTags = args['tags'] as List<dynamic>? ?? [];
79+
final tags = rawTags.map((e) => e.toString()).toList();
80+
81+
final userId = Supabase.instance.client.auth.currentUser?.id;
82+
if (userId == null) {
83+
return 'Vui lòng đăng nhập để tạo công việc.';
84+
}
85+
86+
final dbResponse = await Supabase.instance.client.rpc(
87+
'create_task_full',
88+
params: {
89+
'p_title': title,
90+
'p_priority': priority,
91+
'p_profile_id': userId,
92+
'p_tag_names': tags,
93+
},
94+
);
95+
96+
final isSuccess = dbResponse['success'] == true;
97+
final functionResponse = await _chatSession!.sendMessage(
98+
Content.functionResponse('create_task_full', {
99+
'status': isSuccess ? 'Thành công' : 'Thất bại',
100+
}),
101+
);
102+
return functionResponse.text ?? 'Đã xử lý xong yêu cầu của bạn!';
103+
}
104+
}
105+
return response.text ?? 'Xin lỗi, trợ lý đang bận xíu. Thử lại sau nhé!';
106+
} catch (e) {
107+
final errorString = e.toString();
108+
if (errorString.contains('503')) {
109+
return 'Bạn đợi vài phút rồi chat lại nhé!';
110+
} else if (errorString.contains('429')) {
111+
return 'Bạn chat nhanh quá! Vui lòng chờ chút';
112+
}
113+
return 'Lỗi kết nối AI: $e';
114+
}
115+
}
116+
}

0 commit comments

Comments
 (0)