Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/lib/core/utils/adaptive_color_extension.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';

extension AdaptiveColorExtension on Color {
Color toAdaptiveColor(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
if (!isDark) return this;

return Color.lerp(this, Colors.white, 0.4) ?? this;
}
}

52 changes: 52 additions & 0 deletions src/lib/features/category/model/category_model.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';

class CategoryModel {
final int id;
final String name;
final String colorCode;
final String profileId;

const CategoryModel({
required this.id,
required this.name,
required this.colorCode,
required this.profileId,
});

Color get color => _parseHexColor(colorCode);

factory CategoryModel.fromJson(Map<String, dynamic> json) {
return CategoryModel(
id: (json['id'] as num?)?.toInt() ?? 0,
name: json['name']?.toString() ?? '',
colorCode: json['color_code']?.toString() ?? '#5A8DF3',
profileId: json['profile_id']?.toString() ?? '',
);
}

Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'color_code': colorCode,
'profile_id': profileId,
};
}

static Color _parseHexColor(String value) {
var hex = value.trim().replaceFirst('#', '');
if (hex.length == 6) {
hex = 'FF$hex';
}
if (hex.length != 8) {
return const Color(0xFF5A8DF3);
}

final parsed = int.tryParse(hex, radix: 16);
if (parsed == null) {
return const Color(0xFF5A8DF3);
}
return Color(parsed);
}
}

26 changes: 26 additions & 0 deletions src/lib/features/category/repository/category_repository.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import 'package:supabase_flutter/supabase_flutter.dart';

import '../model/category_model.dart';

class CategoryRepository {
final SupabaseClient _client;

CategoryRepository({SupabaseClient? client})
: _client = client ?? Supabase.instance.client;

Future<List<CategoryModel>> fetchCategories() async {
final user = _client.auth.currentUser;
if (user == null) return [];

final rows = await _client
.from('category')
.select('id, name, color_code, profile_id')
.eq('profile_id', user.id)
.order('name');

return (rows as List<dynamic>)
.map((e) => CategoryModel.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
}

58 changes: 58 additions & 0 deletions src/lib/features/category/view/widgets/category_choice_chips.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:task_management_app/core/utils/adaptive_color_extension.dart';

import '../../model/category_model.dart';

class CategoryChoiceChips extends StatelessWidget {
const CategoryChoiceChips({
super.key,
required this.categories,
required this.selectedCategoryId,
required this.onSelected,
});

final List<CategoryModel> categories;
final int? selectedCategoryId;
final ValueChanged<CategoryModel> onSelected;

@override
Widget build(BuildContext context) {
return SizedBox(
height: 40,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
final adaptiveColor = category.color.toAdaptiveColor(context);
final isSelected = category.id == selectedCategoryId;
return Padding(
padding: const EdgeInsets.only(right: 10),
child: ChoiceChip(
label: Text(category.name),
selected: isSelected,
onSelected: (selected) {
if (selected) onSelected(category);
},
backgroundColor: adaptiveColor.withValues(alpha: 0.15),
selectedColor: adaptiveColor,
labelStyle: TextStyle(
color: isSelected ? Colors.white : adaptiveColor,
fontSize: 14,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: adaptiveColor.withValues(alpha: 0.4),
width: 1,
),
),
showCheckmark: false,
),
);
},
),
);
}
}

41 changes: 41 additions & 0 deletions src/lib/features/category/viewmodel/category_viewmodel.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import 'package:flutter/foundation.dart';

import '../model/category_model.dart';
import '../repository/category_repository.dart';

class CategoryViewModel extends ChangeNotifier {
final CategoryRepository _repository;

CategoryViewModel({CategoryRepository? repository})
: _repository = repository ?? CategoryRepository();

final List<CategoryModel> _categories = [];

List<CategoryModel> get categories => List.unmodifiable(_categories);

bool _isLoading = false;
bool get isLoading => _isLoading;

String? _error;
String? get error => _error;

Future<void> loadCategories() async {
_isLoading = true;
_error = null;
notifyListeners();

try {
final data = await _repository.fetchCategories();
_categories
..clear()
..addAll(data);
} catch (e) {
_error = e.toString();
_categories.clear();
} finally {
_isLoading = false;
notifyListeners();
}
}
}

46 changes: 46 additions & 0 deletions src/lib/features/chatbot/model/chatmessage_model.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import 'dart:convert';

class ChatMessageModel {
final String text;
final bool isUser;
final DateTime timestamp;

ChatMessageModel({
required this.text,
required this.isUser,
DateTime? timestamp,
}) : timestamp = timestamp ?? DateTime.now();

factory ChatMessageModel.fromJson(Map<String, dynamic> json) {
final parsedTimestamp =
DateTime.tryParse(json['timestamp']?.toString() ?? '') ?? DateTime.now();

return ChatMessageModel(
text: json['text']?.toString() ?? '',
isUser: json['isUser'] as bool? ?? true,
timestamp: parsedTimestamp,
);
}

Map<String, dynamic> toJson() {
return {
'text': text,
'isUser': isUser,
'timestamp': timestamp.toIso8601String(),
};
}

static String encodeList(List<ChatMessageModel> messages) {
return jsonEncode(messages.map((message) => message.toJson()).toList());
}

static List<ChatMessageModel> decodeList(String raw) {
final decoded = jsonDecode(raw);
if (decoded is! List) return [];

return decoded
.whereType<Map>()
.map((item) => ChatMessageModel.fromJson(Map<String, dynamic>.from(item)))
.toList();
}
}
116 changes: 116 additions & 0 deletions src/lib/features/chatbot/services/chatbot_services.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

class ChatBotAssistantService {
final String _apiKey = (dotenv.env['GEMINI_API_KEY'] ?? '').trim();
GenerativeModel? _model;
ChatSession? _chatSession;

ChatBotAssistantService() {
if (_apiKey.isEmpty) {
debugPrint("Forget to set GEMINI_API_KEY in .env file");
return;
}

_model = GenerativeModel(
apiKey: _apiKey,
model: 'gemini-2.5-flash',
tools: [
Tool(
functionDeclarations: [
FunctionDeclaration(
'create_task_full',
'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.',
Schema(
SchemaType.object,
properties: {
'title': Schema(
SchemaType.string,
description: 'Tên công việc cần làm',
),
'priority': Schema(
SchemaType.integer,
description:
'Độ ư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.',
),
'tags': Schema(
SchemaType.array,
items: Schema(SchemaType.string),
description:
'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ó.',
),
},
requiredProperties: ['title', 'priority', 'tags'],
),
),
],
),
],
systemInstruction: Content.system(
'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. '
'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 '
'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. '
'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.',
),
);

_chatSession = _model!.startChat();
}

Future<String> sendMessage(String userMessage) async {
if (_chatSession == null) {
return 'Chatbot chưa được cấu hình API key. Vui lòng kiểm tra file .env.';
}

try {
final response = await _chatSession!.sendMessage(
Content.text(userMessage),
);

if (response.functionCalls.isNotEmpty) {
final functionCall = response.functionCalls.first;
if (functionCall.name == 'create_task_full') {
final args = functionCall.args;
final title = args['title'] as String;
final priority = (args['priority'] as num?)?.toInt() ?? 1;
final rawTags = args['tags'] as List<dynamic>? ?? [];
final tags = rawTags.map((e) => e.toString()).toList();

final userId = Supabase.instance.client.auth.currentUser?.id;
if (userId == null) {
return 'Vui lòng đăng nhập để tạo công việc.';
}

final dbResponse = await Supabase.instance.client.rpc(
'create_task_full',
params: {
'p_title': title,
'p_priority': priority,
'p_profile_id': userId,
'p_tag_names': tags,
},
);

final isSuccess = dbResponse['success'] == true;
final functionResponse = await _chatSession!.sendMessage(
Content.functionResponse('create_task_full', {
'status': isSuccess ? 'Thành công' : 'Thất bại',
}),
);
return functionResponse.text ?? 'Đã xử lý xong yêu cầu của bạn!';
}
}
return response.text ?? 'Xin lỗi, trợ lý đang bận xíu. Thử lại sau nhé!';
} catch (e) {
final errorString = e.toString();
if (errorString.contains('503')) {
return 'Bạn đợi vài phút rồi chat lại nhé!';
} else if (errorString.contains('429')) {
return 'Bạn chat nhanh quá! Vui lòng chờ chút';
}
return 'Lỗi kết nối AI: $e';
}
}
}
Loading
Loading