diff --git a/src/lib/core/utils/adaptive_color_extension.dart b/src/lib/core/utils/adaptive_color_extension.dart new file mode 100644 index 0000000..f5c022e --- /dev/null +++ b/src/lib/core/utils/adaptive_color_extension.dart @@ -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; + } +} + diff --git a/src/lib/features/category/model/category_model.dart b/src/lib/features/category/model/category_model.dart new file mode 100644 index 0000000..7bcef4f --- /dev/null +++ b/src/lib/features/category/model/category_model.dart @@ -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 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 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); + } +} + diff --git a/src/lib/features/category/repository/category_repository.dart b/src/lib/features/category/repository/category_repository.dart new file mode 100644 index 0000000..cbfeb93 --- /dev/null +++ b/src/lib/features/category/repository/category_repository.dart @@ -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> 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) + .map((e) => CategoryModel.fromJson(Map.from(e))) + .toList(); + } +} + diff --git a/src/lib/features/category/view/widgets/category_choice_chips.dart b/src/lib/features/category/view/widgets/category_choice_chips.dart new file mode 100644 index 0000000..e9ecf26 --- /dev/null +++ b/src/lib/features/category/view/widgets/category_choice_chips.dart @@ -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 categories; + final int? selectedCategoryId; + final ValueChanged 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, + ), + ); + }, + ), + ); + } +} + diff --git a/src/lib/features/category/viewmodel/category_viewmodel.dart b/src/lib/features/category/viewmodel/category_viewmodel.dart new file mode 100644 index 0000000..c9e1dc0 --- /dev/null +++ b/src/lib/features/category/viewmodel/category_viewmodel.dart @@ -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 _categories = []; + + List get categories => List.unmodifiable(_categories); + + bool _isLoading = false; + bool get isLoading => _isLoading; + + String? _error; + String? get error => _error; + + Future 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(); + } + } +} + diff --git a/src/lib/features/chatbot/model/chatmessage_model.dart b/src/lib/features/chatbot/model/chatmessage_model.dart new file mode 100644 index 0000000..2fb31ae --- /dev/null +++ b/src/lib/features/chatbot/model/chatmessage_model.dart @@ -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 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 toJson() { + return { + 'text': text, + 'isUser': isUser, + 'timestamp': timestamp.toIso8601String(), + }; + } + + static String encodeList(List messages) { + return jsonEncode(messages.map((message) => message.toJson()).toList()); + } + + static List decodeList(String raw) { + final decoded = jsonDecode(raw); + if (decoded is! List) return []; + + return decoded + .whereType() + .map((item) => ChatMessageModel.fromJson(Map.from(item))) + .toList(); + } +} diff --git a/src/lib/features/chatbot/services/chatbot_services.dart b/src/lib/features/chatbot/services/chatbot_services.dart new file mode 100644 index 0000000..70a4f78 --- /dev/null +++ b/src/lib/features/chatbot/services/chatbot_services.dart @@ -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 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? ?? []; + 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'; + } + } +} diff --git a/src/lib/features/chatbot/view/chatbot_view.dart b/src/lib/features/chatbot/view/chatbot_view.dart new file mode 100644 index 0000000..f5e104a --- /dev/null +++ b/src/lib/features/chatbot/view/chatbot_view.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:task_management_app/features/chatbot/view/widgets/chat_header.dart'; +import 'package:task_management_app/features/chatbot/view/widgets/day_separator.dart'; +import 'package:task_management_app/features/chatbot/view/widgets/message_composer.dart'; +import 'package:task_management_app/features/chatbot/view/widgets/message_tile.dart'; +import 'package:task_management_app/features/chatbot/view/widgets/typing_indicator.dart'; + +import '../viewmodel/chatbot_viewmodel.dart'; + +class ChatBotView extends StatelessWidget { + const ChatBotView({super.key, this.userAvatarUrl}); + + final String? userAvatarUrl; + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => ChatBotViewModel(), + child: _ChatBotViewBody(userAvatarUrl: userAvatarUrl), + ); + } +} + +class _ChatBotViewBody extends StatefulWidget { + const _ChatBotViewBody({this.userAvatarUrl}); + + final String? userAvatarUrl; + + @override + State<_ChatBotViewBody> createState() => _ChatBotViewBodyState(); +} + +class _ChatBotViewBodyState extends State<_ChatBotViewBody> { + final TextEditingController _controller = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + Future _sendMessage(ChatBotViewModel viewModel) async { + final text = _controller.text.trim(); + if (text.isEmpty || viewModel.isLoading) return; + + _controller.clear(); + _scrollToBottom(); + + await viewModel.sendMessage(text); + if (!mounted) return; + + _scrollToBottom(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + body: SafeArea( + child: Column( + children: [ + const ChatHeader(), + Divider(height: 1, color: scheme.outline.withValues(alpha: 0.4)), + Expanded( + child: Consumer( + builder: (context, viewModel, _) { + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + itemCount: viewModel.messages.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return const Padding( + padding: EdgeInsets.only(bottom: 16), + child: DaySeparator(label: 'Today'), + ); + } + + final message = viewModel.messages[index - 1]; + return MessageTile( + message: message, + userAvatarUrl: widget.userAvatarUrl, + ); + }, + ); + }, + ), + ), + Consumer( + builder: (context, viewModel, _) { + if (!viewModel.isLoading) return const SizedBox.shrink(); + return const Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 12), + child: TypingIndicator(), + ); + }, + ), + Consumer( + builder: (context, viewModel, _) { + return MessageComposer( + controller: _controller, + isSending: viewModel.isLoading, + onSend: () => _sendMessage(viewModel), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/src/lib/features/chatbot/view/widgets/bot_avatar.dart b/src/lib/features/chatbot/view/widgets/bot_avatar.dart new file mode 100644 index 0000000..d689144 --- /dev/null +++ b/src/lib/features/chatbot/view/widgets/bot_avatar.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +class BotAvatar extends StatelessWidget { + const BotAvatar({super.key, required this.size}); + + final double size; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: scheme.surfaceContainerHighest, + ), + alignment: Alignment.center, + child: Icon( + Icons.smart_toy_rounded, + size: size * 0.58, + color: scheme.primary, + ), + ); + } +} + diff --git a/src/lib/features/chatbot/view/widgets/chat_header.dart b/src/lib/features/chatbot/view/widgets/chat_header.dart new file mode 100644 index 0000000..35e83ff --- /dev/null +++ b/src/lib/features/chatbot/view/widgets/chat_header.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import 'bot_avatar.dart'; + +class ChatHeader extends StatelessWidget { + const ChatHeader({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 10), + child: Row( + children: [ + const BotAvatar(size: 48), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'TaskBot', + style: theme.textTheme.headlineSmall?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.w800, + ), + ), + Text( + 'ONLINE AI ASSISTANT', + style: theme.textTheme.labelLarge?.copyWith( + color: scheme.tertiary, + letterSpacing: 0.6, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + diff --git a/src/lib/features/chatbot/view/widgets/day_separator.dart b/src/lib/features/chatbot/view/widgets/day_separator.dart new file mode 100644 index 0000000..351c129 --- /dev/null +++ b/src/lib/features/chatbot/view/widgets/day_separator.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class DaySeparator extends StatelessWidget { + const DaySeparator({super.key, required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label, + style: TextStyle( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} + diff --git a/src/lib/features/chatbot/view/widgets/message_composer.dart b/src/lib/features/chatbot/view/widgets/message_composer.dart new file mode 100644 index 0000000..7b4988f --- /dev/null +++ b/src/lib/features/chatbot/view/widgets/message_composer.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +class MessageComposer extends StatelessWidget { + const MessageComposer({ + super.key, + required this.controller, + required this.onSend, + required this.isSending, + }); + + final TextEditingController controller; + final VoidCallback onSend; + final bool isSending; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return SafeArea( + minimum: const EdgeInsets.fromLTRB(16, 0, 16, 14), + child: Container( + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: BorderRadius.circular(22), + boxShadow: [ + BoxShadow( + color: scheme.shadow.withValues(alpha: 0.14), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ], + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + child: Row( + children: [ + IconButton( + constraints: const BoxConstraints.tightFor(width: 40, height: 40), + padding: EdgeInsets.zero, + onPressed: () {}, + icon: Icon(Icons.add_circle, color: scheme.onSurfaceVariant), + ), + const SizedBox(width: 6), + Expanded( + child: TextField( + controller: controller, + textInputAction: TextInputAction.send, + enabled: !isSending, + onSubmitted: (_) => onSend(), + style: TextStyle(color: scheme.onSurface), + decoration: InputDecoration( + hintText: 'Type a message or ask for help...', + hintStyle: TextStyle(color: scheme.onSurfaceVariant), + border: InputBorder.none, + ), + ), + ), + IconButton( + constraints: const BoxConstraints.tightFor(width: 40, height: 40), + padding: EdgeInsets.zero, + onPressed: () {}, + icon: Icon(Icons.mic, color: scheme.onSurfaceVariant), + ), + const SizedBox(width: 6), + SizedBox( + width: 44, + height: 44, + child: ElevatedButton( + onPressed: isSending ? null : onSend, + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + padding: EdgeInsets.zero, + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + ), + child: Icon( + Icons.send_rounded, + color: isSending + ? scheme.onPrimary.withValues(alpha: 0.7) + : scheme.onPrimary, + ), + ), + ), + ], + ), + ), + ); + } +} + diff --git a/src/lib/features/chatbot/view/widgets/message_tile.dart b/src/lib/features/chatbot/view/widgets/message_tile.dart new file mode 100644 index 0000000..74546a2 --- /dev/null +++ b/src/lib/features/chatbot/view/widgets/message_tile.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; + +import '../../model/chatmessage_model.dart'; +import 'bot_avatar.dart'; +import 'user_avatar.dart'; + +class MessageTile extends StatelessWidget { + const MessageTile({super.key, required this.message, this.userAvatarUrl}); + + final ChatMessageModel message; + final String? userAvatarUrl; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final isUser = message.isUser; + final crossAxis = isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start; + + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: LayoutBuilder( + builder: (context, constraints) { + final maxBubbleWidth = constraints.maxWidth * 0.72; + + Widget bubble() { + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxBubbleWidth), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: isUser ? scheme.primary : scheme.surface, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: scheme.shadow.withValues(alpha: 0.12), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Text( + _breakLongTokens(message.text), + softWrap: true, + style: theme.textTheme.titleMedium?.copyWith( + height: 1.45, + color: isUser ? scheme.onPrimary : scheme.onSurface, + ), + ), + ), + ); + } + + return Column( + crossAxisAlignment: crossAxis, + children: [ + if (isUser) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: bubble(), + ), + ), + const SizedBox(width: 10), + UserAvatar(size: 36, avatarUrl: userAvatarUrl), + ], + ) + else + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const BotAvatar(size: 36), + const SizedBox(width: 10), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: bubble(), + ), + ), + ], + ), + const SizedBox(height: 6), + Padding( + padding: EdgeInsets.only(left: isUser ? 0 : 46, right: isUser ? 46 : 0), + child: Text( + _formatTime(context, message.timestamp), + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + ], + ); + }, + ), + ); + } + + String _formatTime(BuildContext context, DateTime time) { + final localTime = TimeOfDay.fromDateTime(time); + return MaterialLocalizations.of(context).formatTimeOfDay(localTime); + } + + String _breakLongTokens(String input) { + final tokenRegex = RegExp(r'\S{24,}'); + return input.replaceAllMapped(tokenRegex, (match) { + final token = match.group(0) ?? ''; + final buffer = StringBuffer(); + for (int i = 0; i < token.length; i++) { + buffer.write(token[i]); + if ((i + 1) % 12 == 0 && i + 1 < token.length) { + buffer.write('\u200B'); + } + } + return buffer.toString(); + }); + } +} + diff --git a/src/lib/features/chatbot/view/widgets/typing_indicator.dart b/src/lib/features/chatbot/view/widgets/typing_indicator.dart new file mode 100644 index 0000000..a35d7cc --- /dev/null +++ b/src/lib/features/chatbot/view/widgets/typing_indicator.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import 'bot_avatar.dart'; + +class TypingIndicator extends StatelessWidget { + const TypingIndicator({super.key}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Row( + children: [ + const BotAvatar(size: 34), + const SizedBox(width: 10), + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + 'TaskBot is typing...', + overflow: TextOverflow.ellipsis, + style: TextStyle(color: scheme.onSurfaceVariant), + ), + ), + ), + ], + ); + } +} + diff --git a/src/lib/features/chatbot/view/widgets/user_avatar.dart b/src/lib/features/chatbot/view/widgets/user_avatar.dart new file mode 100644 index 0000000..a8ae00b --- /dev/null +++ b/src/lib/features/chatbot/view/widgets/user_avatar.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class UserAvatar extends StatelessWidget { + const UserAvatar({super.key, required this.size, this.avatarUrl}); + + final double size; + final String? avatarUrl; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final fallbackUrl = + (Supabase.instance.client.auth.currentUser?.userMetadata?['avatar_url'] as String?)?.trim(); + final resolvedUrl = (avatarUrl ?? fallbackUrl ?? '').trim(); + final canLoadNetworkImage = _isValidHttpUrl(resolvedUrl); + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: scheme.surfaceContainerHighest, + ), + clipBehavior: Clip.antiAlias, + child: canLoadNetworkImage + ? Image.network( + resolvedUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Icon( + Icons.person, + size: size * 0.6, + color: scheme.primary, + ), + ) + : Icon(Icons.person, size: size * 0.6, color: scheme.primary), + ); + } + + bool _isValidHttpUrl(String value) { + if (value.isEmpty || value.length > 2048) return false; + final uri = Uri.tryParse(value); + if (uri == null) return false; + return uri.hasAbsolutePath && (uri.scheme == 'http' || uri.scheme == 'https'); + } +} + diff --git a/src/lib/features/chatbot/viewmodel/chatbot_viewmodel.dart b/src/lib/features/chatbot/viewmodel/chatbot_viewmodel.dart new file mode 100644 index 0000000..fd5a4d3 --- /dev/null +++ b/src/lib/features/chatbot/viewmodel/chatbot_viewmodel.dart @@ -0,0 +1,90 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../model/chatmessage_model.dart'; +import '../services/chatbot_services.dart'; + +class ChatBotViewModel extends ChangeNotifier { + static const String _historyKey = 'chatbot_history_v1'; + static const int _maxHistoryMessages = 200; + + final _aiService = ChatBotAssistantService(); + final List _messages = []; + + ChatBotViewModel() { + _loadHistory(); + } + + List _initialMessages() => [ + ChatMessageModel( + text: 'Chào bạn! Tôi là trợ lý năng suất. Hôm nay bạn cần tôi giúp gì?', + isUser: false, + ), + ]; + + List get messages => _messages; + + bool _isLoading = false; + + bool get isLoading => _isLoading; + + Future _loadHistory() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_historyKey); + + if (raw == null || raw.trim().isEmpty) { + _messages + ..clear() + ..addAll(_initialMessages()); + await _saveHistory(); + } else { + final storedMessages = ChatMessageModel.decodeList(raw); + _messages + ..clear() + ..addAll( + storedMessages.isEmpty ? _initialMessages() : storedMessages, + ); + } + } catch (e) { + debugPrint('Error loading chatbot history: $e'); + _messages + ..clear() + ..addAll(_initialMessages()); + } + + notifyListeners(); + } + + Future _saveHistory() async { + try { + final prefs = await SharedPreferences.getInstance(); + if (_messages.length > _maxHistoryMessages) { + _messages.removeRange(0, _messages.length - _maxHistoryMessages); + } + await prefs.setString( + _historyKey, + ChatMessageModel.encodeList(_messages), + ); + } catch (e) { + debugPrint('Error saving chatbot history: $e'); + } + } + + Future sendMessage(String text) async { + final normalizedText = text.trim(); + if (normalizedText.isEmpty) return; + + _messages.add(ChatMessageModel(text: normalizedText, isUser: true)); + _isLoading = true; + notifyListeners(); + await _saveHistory(); + + final response = await _aiService.sendMessage(normalizedText); + + _messages.add(ChatMessageModel(text: response, isUser: false)); + _isLoading = false; + await _saveHistory(); + notifyListeners(); + } +} diff --git a/src/lib/features/main/view/screens/main_screen.dart b/src/lib/features/main/view/screens/main_screen.dart index cb1c9d0..50c09e3 100644 --- a/src/lib/features/main/view/screens/main_screen.dart +++ b/src/lib/features/main/view/screens/main_screen.dart @@ -1,15 +1,18 @@ import 'package:flutter/material.dart'; +// import '../../../tasks/view/screens/home_screen.dart'; +import 'package:provider/provider.dart'; +import 'package:task_management_app/features/chatbot/view/chatbot_view.dart'; import 'package:task_management_app/features/statistics/viewmodel/statistics_viewmodel.dart'; import 'package:task_management_app/features/tasks/view/screens/home_screen.dart'; -import 'settings_screen.dart'; import 'package:task_management_app/features/user/viewmodel/user_profile_viewmodel.dart'; + +import '../../../category/viewmodel/category_viewmodel.dart'; import '../../../note/view/focus_screen.dart'; import '../../../note/viewmodel/focus_viewmodel.dart'; import '../../../statistics/view/screens/statistics_screen.dart'; -// import '../../../tasks/view/screens/home_screen.dart'; -import 'package:provider/provider.dart'; - +import '../../../tag/viewmodel/tag_viewmodel.dart'; import '../../../user/view/user_profile_view.dart'; +import 'settings_screen.dart'; class MainScreen extends StatefulWidget { const MainScreen({super.key}); @@ -23,47 +26,63 @@ class _MainScreenState extends State { final List _screens = [ const Center(child: HomeScreen()), - const Center(child: Text('Màn hình Lịch')), + const ChatBotView(), ChangeNotifierProvider( create: (_) => FocusViewModel(), child: const FocusScreen(), ), ChangeNotifierProvider( - create: (_) => StatisticsViewmodel(), - child: const StatisticsScreen(), + create: (_) => StatisticsViewmodel(), + child: const StatisticsScreen(), ), ChangeNotifierProvider( - create: (_) => UserProfileViewModel(useMockData: true)..loadProfile(), - child: const UserProfileView(), + create: (_) => UserProfileViewModel(useMockData: true)..loadProfile(), + child: const UserProfileView(), ), const SettingsScreen(), ]; + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadCategories(); + context.read().loadTags(); + }); + } + @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return Scaffold( extendBody: true, - body: IndexedStack( - index: _currentIndex, - children: _screens, - ), + body: IndexedStack(index: _currentIndex, children: _screens), bottomNavigationBar: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 15), decoration: BoxDecoration( color: isDark ? const Color(0xFF1A2945) : Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.only(topLeft: Radius.circular(30), topRight: Radius.circular(30)), - boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 20, offset: const Offset(0, -5))], + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(30), + topRight: Radius.circular(30), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 20, + offset: const Offset(0, -5), + ), + ], ), child: SafeArea( child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _buildNavItem(context, Icons.checklist_rounded, 'Công việc', 0), - _buildNavItem(context, Icons.calendar_today_rounded, 'Lịch', 1), + _buildNavItem(context, Icons.smart_toy_rounded, 'Chat', 1), _buildNavItem(context, Icons.timer_rounded, 'Tập trung', 2), _buildNavItem(context, Icons.bar_chart_rounded, 'Thống kê', 3), _buildNavItem(context, Icons.person_2_rounded, 'Cá nhân', 4), @@ -74,7 +93,12 @@ class _MainScreenState extends State { ); } - Widget _buildNavItem(BuildContext context, IconData icon, String label, int index) { + Widget _buildNavItem( + BuildContext context, + IconData icon, + String label, + int index, + ) { bool isSelected = _currentIndex == index; return GestureDetector( @@ -82,12 +106,15 @@ class _MainScreenState extends State { child: AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, - padding: EdgeInsets.symmetric(horizontal: isSelected ? 15 : 10, vertical: 10), + padding: EdgeInsets.symmetric( + horizontal: isSelected ? 15 : 10, + vertical: 10, + ), decoration: BoxDecoration( color: isSelected ? (Theme.of(context).brightness == Brightness.dark - ? const Color(0xFF23395D) - : const Color(0xFFE8F0FE)) + ? const Color(0xFF23395D) + : const Color(0xFFE8F0FE)) : Colors.transparent, borderRadius: BorderRadius.circular(15), ), @@ -111,10 +138,10 @@ class _MainScreenState extends State { ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurfaceVariant, ), - ) + ), ], ), ), ); } -} \ No newline at end of file +} diff --git a/src/lib/features/statistics/view/screens/statistics_screen.dart b/src/lib/features/statistics/view/screens/statistics_screen.dart index fd18a14..f7bd920 100644 --- a/src/lib/features/statistics/view/screens/statistics_screen.dart +++ b/src/lib/features/statistics/view/screens/statistics_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:task_management_app/features/category/viewmodel/category_viewmodel.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:provider/provider.dart'; import '../../viewmodel/statistics_viewmodel.dart'; @@ -23,6 +24,10 @@ class _StatisticsScreenState extends State { final userId = Supabase.instance.client.auth.currentUser?.id; if (userId != null) { context.read().getStatisticsData(userId); + final categoryViewModel = context.read(); + if (categoryViewModel.categories.isEmpty) { + categoryViewModel.loadCategories(); + } } }); } diff --git a/src/lib/features/statistics/view/widgets/statistics_widgets.dart b/src/lib/features/statistics/view/widgets/statistics_widgets.dart index c0b6568..e1e3598 100644 --- a/src/lib/features/statistics/view/widgets/statistics_widgets.dart +++ b/src/lib/features/statistics/view/widgets/statistics_widgets.dart @@ -1,5 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:task_management_app/features/category/model/category_model.dart'; +import 'package:task_management_app/features/category/viewmodel/category_viewmodel.dart'; import 'package:task_management_app/features/statistics/model/StatisticsModel.dart'; + import '../../../tasks/model/task_model.dart'; import '../../../tasks/view/screens/task_detail_screen.dart'; @@ -7,7 +11,13 @@ class DailyProgressCard extends StatelessWidget { final int total; final int completed; final double percentage; - const DailyProgressCard({super.key, required this.total, required this.completed, required this.percentage}); + + const DailyProgressCard({ + super.key, + required this.total, + required this.completed, + required this.percentage, + }); @override Widget build(BuildContext context) { @@ -17,7 +27,9 @@ class DailyProgressCard extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 20), decoration: BoxDecoration( - color: isDark ? const Color(0xFF1A2945) : Theme.of(context).colorScheme.surface, + color: isDark + ? const Color(0xFF1A2945) + : Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(30), border: isDark ? Border.all(color: const Color(0xFF2A3E62), width: 1) @@ -43,8 +55,9 @@ class DailyProgressCard extends StatelessWidget { CircularProgressIndicator( value: (total > 0) ? percentage / 100 : 0, strokeWidth: 12, - backgroundColor: - Theme.of(context).colorScheme.surfaceContainerHighest, + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, valueColor: AlwaysStoppedAnimation( Theme.of(context).colorScheme.primary, ), @@ -99,7 +112,6 @@ class DailyProgressCard extends StatelessWidget { } } - class WeeklyChartCard extends StatelessWidget { final int selectedIndex; final int thisWeekTotal; @@ -121,17 +133,22 @@ class WeeklyChartCard extends StatelessWidget { final isDark = Theme.of(context).brightness == Brightness.dark; final bool isPositive = growthPercentage >= 0; - final Color trendColor = isPositive ? const Color(0xFF3DDC84) : Colors.redAccent; + final Color trendColor = isPositive + ? const Color(0xFF3DDC84) + : Colors.redAccent; final Color trendBgColor = isPositive ? (isDark ? const Color(0xFF173B3D) : const Color(0xFFE9F7EF)) : (isDark ? const Color(0xFF402129) : const Color(0xFFFFEBEE)); - final String trendText = "${isPositive ? '+' : ''}$growthPercentage% vs tuần trước"; + final String trendText = + "${isPositive ? '+' : ''}$growthPercentage% vs tuần trước"; return Container( width: double.infinity, padding: const EdgeInsets.all(25), decoration: BoxDecoration( - color: isDark ? const Color(0xFF1A2945) : Theme.of(context).colorScheme.surface, + color: isDark + ? const Color(0xFF1A2945) + : Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(30), border: isDark ? Border.all(color: const Color(0xFF2A3E62), width: 1) @@ -167,9 +184,22 @@ class WeeklyChartCard extends StatelessWidget { ], ), Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration(color: trendBgColor, borderRadius: BorderRadius.circular(15)), - child: Text(trendText, style: TextStyle(color: trendColor, fontSize: 12, fontWeight: FontWeight.bold)), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: trendBgColor, + borderRadius: BorderRadius.circular(15), + ), + child: Text( + trendText, + style: TextStyle( + color: trendColor, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), ), ], ), @@ -178,14 +208,48 @@ class WeeklyChartCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.end, children: [ - - _buildBar(context, 'T2', weeklyHeights.length > 0 ? weeklyHeights[0] : 0.1, 0), - _buildBar(context, 'T3', weeklyHeights.length > 1 ? weeklyHeights[1] : 0.1, 1), - _buildBar(context, 'T4', weeklyHeights.length > 2 ? weeklyHeights[2] : 0.1, 2), - _buildBar(context, 'T5', weeklyHeights.length > 3 ? weeklyHeights[3] : 0.1, 3), - _buildBar(context, 'T6', weeklyHeights.length > 4 ? weeklyHeights[4] : 0.1, 4), - _buildBar(context, 'T7', weeklyHeights.length > 5 ? weeklyHeights[5] : 0.1, 5), - _buildBar(context, 'CN', weeklyHeights.length > 6 ? weeklyHeights[6] : 0.1, 6), + _buildBar( + context, + 'T2', + weeklyHeights.length > 0 ? weeklyHeights[0] : 0.1, + 0, + ), + _buildBar( + context, + 'T3', + weeklyHeights.length > 1 ? weeklyHeights[1] : 0.1, + 1, + ), + _buildBar( + context, + 'T4', + weeklyHeights.length > 2 ? weeklyHeights[2] : 0.1, + 2, + ), + _buildBar( + context, + 'T5', + weeklyHeights.length > 3 ? weeklyHeights[3] : 0.1, + 3, + ), + _buildBar( + context, + 'T6', + weeklyHeights.length > 4 ? weeklyHeights[4] : 0.1, + 4, + ), + _buildBar( + context, + 'T7', + weeklyHeights.length > 5 ? weeklyHeights[5] : 0.1, + 5, + ), + _buildBar( + context, + 'CN', + weeklyHeights.length > 6 ? weeklyHeights[6] : 0.1, + 6, + ), ], ), ], @@ -193,7 +257,12 @@ class WeeklyChartCard extends StatelessWidget { ); } - Widget _buildBar(BuildContext context, String label, double heightRatio, int index) { + Widget _buildBar( + BuildContext context, + String label, + double heightRatio, + int index, + ) { bool isActive = index == selectedIndex; return GestureDetector( onTap: () => onDaySelected(index), @@ -203,13 +272,13 @@ class WeeklyChartCard extends StatelessWidget { AnimatedContainer( duration: const Duration(milliseconds: 300), width: 35, - height: 100 * (heightRatio > 0 ? heightRatio : 0.1), // Tối thiểu 10% để cột không bị "biến mất" + height: 100 * (heightRatio > 0 ? heightRatio : 0.1), decoration: BoxDecoration( color: isActive ? Theme.of(context).colorScheme.primary : (Theme.of(context).brightness == Brightness.dark - ? const Color(0xFF334764) - : const Color(0xFFF5F7FA)), + ? const Color(0xFF334764) + : const Color(0xFFF5F7FA)), borderRadius: BorderRadius.circular(8), ), ), @@ -239,10 +308,22 @@ class CompletedTaskCard extends StatelessWidget { @override Widget build(BuildContext context) { + final categoryViewModel = context.watch(); final isDark = Theme.of(context).brightness == Brightness.dark; + final CategoryModel fallbackCategory = CategoryModel( + id: 0, + name: 'General', + colorCode: '#5A8DF3', + profileId: '', + ); + final CategoryModel category = categoryViewModel.categories.isNotEmpty + ? categoryViewModel.categories.first + : fallbackCategory; final time = task.updatedAt; - final hour = time.hour > 12 ? time.hour - 12 : (time.hour == 0 ? 12 : time.hour); + final hour = time.hour > 12 + ? time.hour - 12 + : (time.hour == 0 ? 12 : time.hour); final minute = time.minute.toString().padLeft(2, '0'); final period = time.hour >= 12 ? 'PM' : 'AM'; final timeString = 'Hoàn thành lúc $hour:$minute $period'; @@ -255,28 +336,41 @@ class CompletedTaskCard extends StatelessWidget { borderRadius: BorderRadius.circular(25), onTap: () { final mappedTask = TaskModel( - id: task.id.toString(), // Convert int to String if your TaskModel uses String IDs + id: task.id.toString(), + // Convert int to String if your TaskModel uses String IDs title: task.title, - description: 'Completed task details from Statistics.', // Default filler - category: 'Development', // Default filler - startTime: TimeOfDay(hour: task.updatedAt.hour, minute: task.updatedAt.minute), - endTime: TimeOfDay(hour: task.updatedAt.hour + 1, minute: task.updatedAt.minute), // Add 1 hour just for display + description: 'Completed task details from Statistics.', + category: category, + startTime: TimeOfDay( + hour: task.updatedAt.hour, + minute: task.updatedAt.minute, + ), + endTime: TimeOfDay( + hour: task.updatedAt.hour + 1, + minute: task.updatedAt.minute, + ), + // Add 1 hour just for display date: task.updatedAt, ); - Navigator.push(context, PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 500), - pageBuilder: (_, __, ___) => TaskDetailScreen(task: mappedTask), - transitionsBuilder: (_, animation, __, child) { - return FadeTransition(opacity: animation, child: child); - }, - )); + Navigator.push( + context, + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 500), + pageBuilder: (_, __, ___) => TaskDetailScreen(task: mappedTask), + transitionsBuilder: (_, animation, __, child) { + return FadeTransition(opacity: animation, child: child); + }, + ), + ); }, child: Container( margin: const EdgeInsets.only(bottom: 15), padding: const EdgeInsets.all(20), decoration: BoxDecoration( - color: isDark ? const Color(0xFF1A2945) : Theme.of(context).colorScheme.surface, + color: isDark + ? const Color(0xFF1A2945) + : Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(25), border: isDark ? Border.all(color: const Color(0xFF2A3E62), width: 1) @@ -318,7 +412,11 @@ class CompletedTaskCard extends StatelessWidget { ], ), ), - const Icon(Icons.check_circle, color: Color(0xFF2ECC71), size: 28), + const Icon( + Icons.check_circle, + color: Color(0xFF2ECC71), + size: 28, + ), ], ), ), @@ -326,4 +424,4 @@ class CompletedTaskCard extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/src/lib/features/tag/model/tag_model.dart b/src/lib/features/tag/model/tag_model.dart new file mode 100644 index 0000000..5f5fdb8 --- /dev/null +++ b/src/lib/features/tag/model/tag_model.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +class TagModel { + final int id; + final String name; + final String colorCode; + final String profileId; + + const TagModel({ + required this.id, + required this.name, + required this.colorCode, + required this.profileId, + }); + + Color get color => _parseHexColor(colorCode); + + factory TagModel.fromJson(Map json) { + return TagModel( + id: (json['id'] as num?)?.toInt() ?? 0, + name: json['name']?.toString() ?? '', + colorCode: json['color_code']?.toString() ?? '#4A90E2', + profileId: json['profile_id']?.toString() ?? '', + ); + } + + Map 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(0xFF4A90E2); + } + + final parsed = int.tryParse(hex, radix: 16); + if (parsed == null) { + return const Color(0xFF4A90E2); + } + return Color(parsed); + } +} + diff --git a/src/lib/features/tag/repository/tag_repository.dart b/src/lib/features/tag/repository/tag_repository.dart new file mode 100644 index 0000000..d2c9158 --- /dev/null +++ b/src/lib/features/tag/repository/tag_repository.dart @@ -0,0 +1,45 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../model/tag_model.dart'; + +class TagRepository { + final SupabaseClient _client; + + TagRepository({SupabaseClient? client}) + : _client = client ?? Supabase.instance.client; + + Future> fetchTags() async { + final user = _client.auth.currentUser; + if (user == null) return []; + + final rows = await _client + .from('tag') + .select('id, name, color_code, profile_id') + .eq('profile_id', user.id) + .order('name'); + + return (rows as List) + .map((e) => TagModel.fromJson(Map.from(e))) + .toList(); + } + + Future createCustomTag(String name, String colorCode) async { + final user = _client.auth.currentUser; + if (user == null) { + throw Exception('User is not authenticated'); + } + + final inserted = await _client + .from('tag') + .insert({ + 'name': name, + 'color_code': colorCode, + 'profile_id': user.id, + }) + .select('id, name, color_code, profile_id') + .single(); + + return TagModel.fromJson(Map.from(inserted)); + } +} + diff --git a/src/lib/features/tag/view/widgets/tag_selector.dart b/src/lib/features/tag/view/widgets/tag_selector.dart new file mode 100644 index 0000000..50753a6 --- /dev/null +++ b/src/lib/features/tag/view/widgets/tag_selector.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:task_management_app/core/utils/adaptive_color_extension.dart'; +import 'package:task_management_app/features/tag/model/tag_model.dart'; + +import '../../viewmodel/tag_viewmodel.dart'; + +class TagSelector extends StatefulWidget { + const TagSelector({super.key}); + + @override + State createState() => _TagSelectorState(); +} + +class _TagSelectorState extends State { + final TextEditingController _customController = TextEditingController(); + + @override + void dispose() { + _customController.dispose(); + super.dispose(); + } + + void _showAddCustomDialog(BuildContext context, TagViewModel viewModel) { + _customController.clear(); + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Tạo tag mới'), + content: TextField( + controller: _customController, + maxLength: 12, + decoration: const InputDecoration( + hintText: 'Tên tag (tối đa 12 ký tự)', + border: OutlineInputBorder(), + ), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Huỷ'), + ), + ElevatedButton( + onPressed: () async { + final error = await viewModel.addCustomTag(_customController.text); + if (!context.mounted) return; + + if (error != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } else { + Navigator.pop(context); + } + }, + child: const Text('Thêm'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final viewModel = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Tags', style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Custom', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + GestureDetector( + onTap: viewModel.isLoading + ? null + : () => _showAddCustomDialog(context, viewModel), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Theme.of(context).colorScheme.outline), + ), + child: Row( + children: [ + Icon( + Icons.add, + size: 14, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 3), + Text( + 'Tạo tag', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 8), + if (viewModel.tags.isEmpty) + Text( + 'Chưa có tag. Nhấn "Tạo tag" để thêm.', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ) + else + Wrap( + spacing: 8, + runSpacing: 8, + children: viewModel.tags + .map( + (tag) => _TagChip( + tag: tag, + isSelected: viewModel.isTagSelected(tag), + onTap: () => viewModel.toggleTag(tag), + ), + ) + .toList(), + ), + ], + ); + } +} + +class _TagChip extends StatelessWidget { + const _TagChip({ + required this.tag, + required this.isSelected, + required this.onTap, + }); + + final TagModel tag; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final adaptiveTagColor = tag.color.toAdaptiveColor(context); + + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: isSelected + ? adaptiveTagColor + : adaptiveTagColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelected) ...[ + const Icon(Icons.check, color: Colors.white, size: 14), + const SizedBox(width: 4), + ], + Text( + tag.name, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: isSelected ? Colors.white : adaptiveTagColor, + ), + ), + ], + ), + ), + ); + } +} + diff --git a/src/lib/features/tag/viewmodel/tag_viewmodel.dart b/src/lib/features/tag/viewmodel/tag_viewmodel.dart new file mode 100644 index 0000000..d361c77 --- /dev/null +++ b/src/lib/features/tag/viewmodel/tag_viewmodel.dart @@ -0,0 +1,113 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../model/tag_model.dart'; +import '../repository/tag_repository.dart'; + +class TagViewModel extends ChangeNotifier { + static const int _maxCustomTagLength = 12; + + final TagRepository _repository; + + TagViewModel({TagRepository? repository}) + : _repository = repository ?? TagRepository(); + + final List _tags = []; + final Set _selectedTagIds = {}; + + List get tags => List.unmodifiable(_tags); + + List get selectedTags => + _tags.where((tag) => _selectedTagIds.contains(tag.id)).toList(); + + bool _isLoading = false; + bool get isLoading => _isLoading; + + String? _error; + String? get error => _error; + + Future loadTags() async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final data = await _repository.fetchTags(); + _tags + ..clear() + ..addAll(data); + _selectedTagIds.removeWhere((id) => !_tags.any((tag) => tag.id == id)); + } catch (e) { + _error = e.toString(); + _tags.clear(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + bool isTagSelected(TagModel tag) => _selectedTagIds.contains(tag.id); + + void toggleTag(TagModel tag) { + if (_selectedTagIds.contains(tag.id)) { + _selectedTagIds.remove(tag.id); + } else { + _selectedTagIds.add(tag.id); + } + notifyListeners(); + } + + void setSelectedTags(List tags) { + _selectedTagIds + ..clear() + ..addAll(tags.map((e) => e.id)); + notifyListeners(); + } + + void resetSelection() { + _selectedTagIds.clear(); + notifyListeners(); + } + + Future addCustomTag(String name) async { + final trimmed = name.trim(); + if (trimmed.isEmpty) return 'Tên tag không được để trống'; + if (trimmed.length > _maxCustomTagLength) { + return 'Tối đa $_maxCustomTagLength ký tự'; + } + if (_tags.any((t) => t.name.toLowerCase() == trimmed.toLowerCase())) { + return 'Tag đã tồn tại'; + } + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final colorCode = _generateColorCode(); + final created = await _repository.createCustomTag(trimmed, colorCode); + _tags.add(created); + _selectedTagIds.add(created.id); + return null; + } catch (e) { + _error = e.toString(); + return 'Không thể tạo tag. Vui lòng thử lại'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + String _generateColorCode() { + const palette = [ + '#E91E63', + '#673AB7', + '#795548', + '#009688', + '#FF5722', + '#4A90E2', + ]; + return palette[_tags.length % palette.length]; + } +} + diff --git a/src/lib/features/tasks/model/task_model.dart b/src/lib/features/tasks/model/task_model.dart index a6a64fb..3fa2db1 100644 --- a/src/lib/features/tasks/model/task_model.dart +++ b/src/lib/features/tasks/model/task_model.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:task_management_app/features/category/model/category_model.dart'; +import 'package:task_management_app/features/tag/model/tag_model.dart'; + // ─── Priority Enum ─────────────────────────────────────────── enum Priority { low, medium, high, urgent } @@ -44,21 +47,12 @@ extension PriorityExtension on Priority { } } -// ─── Tag Model ─────────────────────────────────────────────── -class TagModel { - final String id; - final String name; - final Color color; - - const TagModel({required this.id, required this.name, required this.color}); -} - // ─── Task Model ────────────────────────────────────────────── class TaskModel { final String id; String title; String description; - String category; + CategoryModel category; TimeOfDay startTime; TimeOfDay endTime; DateTime date; diff --git a/src/lib/features/tasks/view/screens/create_task_screen.dart b/src/lib/features/tasks/view/screens/create_task_screen.dart index 311fd00..7e6fc3e 100644 --- a/src/lib/features/tasks/view/screens/create_task_screen.dart +++ b/src/lib/features/tasks/view/screens/create_task_screen.dart @@ -1,13 +1,16 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; -import '../../../../core/theme/app_colors.dart'; +import 'package:task_management_app/features/category/view/widgets/category_choice_chips.dart'; +import 'package:task_management_app/features/category/viewmodel/category_viewmodel.dart'; +import 'package:task_management_app/features/tag/view/widgets/tag_selector.dart'; +import 'package:task_management_app/features/tag/viewmodel/tag_viewmodel.dart'; + import '../../../../core/widgets/custom_input_field.dart'; import '../../model/task_model.dart'; import '../../viewmodel/task_viewmodel.dart'; import '../widgets/task_widgets.dart'; import '../widgets/priority_selector.dart'; -import '../widgets/tag_selector.dart'; class CreateTaskScreen extends StatefulWidget { const CreateTaskScreen({super.key}); @@ -26,12 +29,28 @@ class _CreateTaskScreenState extends State { DateTime _selectedDate = DateTime.now(); TimeOfDay _startTime = const TimeOfDay(hour: 10, minute: 0); TimeOfDay _endTime = const TimeOfDay(hour: 11, minute: 0); - int _selectedCategoryIndex = 0; + int? _selectedCategoryId; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().loadCategories(); + context.read().loadTags(); + }); + } @override Widget build(BuildContext context) { + final categoryViewModel = context.watch(); + final tagViewModel = context.watch(); String formattedDate = DateFormat('EEEE, d MMMM').format(_selectedDate); - final isDark = Theme.of(context).brightness == Brightness.dark; + final categories = categoryViewModel.categories; + + if (_selectedCategoryId == null && categories.isNotEmpty) { + _selectedCategoryId = categories.first.id; + } return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, @@ -105,47 +124,20 @@ class _CreateTaskScreenState extends State { ), const SizedBox(height: 10), SizedBox( - height: 40, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: 4, - itemBuilder: (context, index) { - List categories = [ - 'Development', - 'Research', - 'Design', - 'Backend', - ]; - bool isSelected = index == _selectedCategoryIndex; - return Padding( - padding: const EdgeInsets.only(right: 10), - child: ChoiceChip( - label: Text(categories[index]), - selected: isSelected, - onSelected: (selected) => setState(() => _selectedCategoryIndex = selected ? index : 0), - backgroundColor: isDark - ? Theme.of(context).colorScheme.surfaceContainerHighest - : const Color(0xFFF1F7FD), - selectedColor: Theme.of(context).colorScheme.primary, - labelStyle: TextStyle( - color: isSelected - ? Colors.white - : Theme.of(context).colorScheme.primary, - fontSize: 14, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: BorderSide( - color: isDark - ? Theme.of(context).colorScheme.outline - : const Color(0xFFF1F7FD), - width: 1, - )), - showCheckmark: false, + child: categories.isEmpty + ? Text( + categoryViewModel.isLoading + ? 'Loading categories...' + : 'No categories found', + style: Theme.of(context).textTheme.bodyMedium, + ) + : CategoryChoiceChips( + categories: categories, + selectedCategoryId: _selectedCategoryId, + onSelected: (category) { + setState(() => _selectedCategoryId = category.id); + }, ), - ); - }, - ), ), const SizedBox(height: 20), @@ -270,26 +262,35 @@ class _CreateTaskScreenState extends State { child: ElevatedButton( onPressed: () { final viewModel = context.read(); - final List categories = [ - 'Development', - 'Research', - 'Design', - 'Backend', - ]; + if (categories.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please create a category first.'), + ), + ); + return; + } + + final selectedCategory = categories.firstWhere( + (category) => category.id == _selectedCategoryId, + orElse: () => categories.first, + ); + final newTask = TaskModel( id: DateTime.now().millisecondsSinceEpoch .toString(), title: _nameController.text, description: _descController.text, - category: categories[_selectedCategoryIndex], + category: selectedCategory, startTime: _startTime, endTime: _endTime, date: _selectedDate, priority: viewModel.selectedPriority, - tags: List.from(viewModel.selectedTags), + tags: List.from(tagViewModel.selectedTags), ); viewModel.addTask(newTask); viewModel.reset(); + context.read().resetSelection(); Navigator.pop(context); }, style: ElevatedButton.styleFrom( diff --git a/src/lib/features/tasks/view/screens/task_detail_screen.dart b/src/lib/features/tasks/view/screens/task_detail_screen.dart index fd2f832..54b628e 100644 --- a/src/lib/features/tasks/view/screens/task_detail_screen.dart +++ b/src/lib/features/tasks/view/screens/task_detail_screen.dart @@ -1,7 +1,13 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; -import '../../../../core/theme/app_colors.dart'; +import 'package:task_management_app/core/utils/adaptive_color_extension.dart'; +import 'package:task_management_app/features/category/model/category_model.dart'; +import 'package:task_management_app/features/category/view/widgets/category_choice_chips.dart'; +import 'package:task_management_app/features/category/viewmodel/category_viewmodel.dart'; +import 'package:task_management_app/features/tag/model/tag_model.dart'; +import 'package:task_management_app/features/tag/viewmodel/tag_viewmodel.dart'; + import '../../../../core/widgets/custom_input_field.dart'; import '../../model/task_model.dart'; import '../../viewmodel/task_viewmodel.dart'; @@ -20,7 +26,7 @@ class _TaskDetailScreenState extends State { late TextEditingController _descController; late TimeOfDay _startTime; late TimeOfDay _endTime; - late String _currentCategory; + late CategoryModel _currentCategory; late List _currentTags; @override @@ -32,6 +38,12 @@ class _TaskDetailScreenState extends State { _endTime = widget.task.endTime; _currentCategory = widget.task.category; _currentTags = List.from(widget.task.tags); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().loadCategories(); + context.read().loadTags(); + }); } @override @@ -74,12 +86,17 @@ class _TaskDetailScreenState extends State { @override Widget build(BuildContext context) { - final viewModel = context.watch(); + final categoryViewModel = context.watch(); + final tagViewModel = context.watch(); String formattedDate = DateFormat('EEEE, d MMMM').format(widget.task.date); final isDark = Theme.of(context).brightness == Brightness.dark; - // Mock categories (Fetch from database later) - List categories = ['Development', 'Research', 'Design', 'Backend']; + final categories = categoryViewModel.categories; + final tags = tagViewModel.tags; + + if (categories.isNotEmpty && !categories.any((c) => c.id == _currentCategory.id)) { + _currentCategory = categories.first; + } return Scaffold( backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, @@ -143,48 +160,20 @@ class _TaskDetailScreenState extends State { ), const SizedBox(height: 10), SizedBox( - height: 40, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: categories.length, - itemBuilder: (context, index) { - bool isSelected = - categories[index] == _currentCategory; - return Padding( - padding: const EdgeInsets.only(right: 10), - child: ChoiceChip( - label: Text(categories[index]), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setState( - () => _currentCategory = categories[index], - ); - } + child: categories.isEmpty + ? Text( + categoryViewModel.isLoading + ? 'Loading categories...' + : 'No categories found', + style: Theme.of(context).textTheme.bodyMedium, + ) + : CategoryChoiceChips( + categories: categories, + selectedCategoryId: _currentCategory.id, + onSelected: (category) { + setState(() => _currentCategory = category); }, - backgroundColor: isDark - ? Theme.of(context).colorScheme.surfaceContainerHighest - : const Color(0xFFF1F7FD), - selectedColor: Theme.of(context).colorScheme.primary, - labelStyle: TextStyle( - color: isSelected - ? Colors.white - : Theme.of(context).colorScheme.primary, - fontSize: 14, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: BorderSide( - color: isDark - ? Theme.of(context).colorScheme.outline - : const Color(0xFFF1F7FD), - width: 1, - )), - showCheckmark: false, ), - ); - }, - ), ), const SizedBox(height: 25), @@ -201,70 +190,17 @@ class _TaskDetailScreenState extends State { ), const SizedBox(height: 25), - // ─── Time Tags ──────────────────────────── - Text( - 'Thời gian', - style: Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: viewModel.timeTags.map((tag) { - final isSelected = _isTagSelected(tag); - return GestureDetector( - onTap: () => _toggleTag(tag), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 8, - ), - decoration: BoxDecoration( - color: isSelected - ? tag.color - : tag.color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isSelected) ...[ - const Icon( - Icons.check, - color: Colors.white, - size: 14, - ), - const SizedBox(width: 4), - ], - Text( - tag.name, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: isSelected - ? Colors.white - : tag.color, - ), - ), - ], - ), - ), - ); - }).toList(), - ), - const SizedBox(height: 20), - - // ─── Status Tags ────────────────────────── + // ─── Tags ───────────────────────────────── Text( - 'Trạng thái', + 'Tags', style: Theme.of(context).textTheme.labelLarge, ), const SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 8, - children: viewModel.statusTags.map((tag) { + children: tags.map((tag) { + final adaptiveTagColor = tag.color.toAdaptiveColor(context); final isSelected = _isTagSelected(tag); return GestureDetector( onTap: () => _toggleTag(tag), @@ -276,8 +212,8 @@ class _TaskDetailScreenState extends State { ), decoration: BoxDecoration( color: isSelected - ? tag.color - : tag.color.withValues(alpha: 0.1), + ? adaptiveTagColor + : adaptiveTagColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(20), ), child: Row( @@ -298,7 +234,7 @@ class _TaskDetailScreenState extends State { fontWeight: FontWeight.w500, color: isSelected ? Colors.white - : tag.color, + : adaptiveTagColor, ), ), ], diff --git a/src/lib/features/tasks/view/widgets/tag_selector.dart b/src/lib/features/tasks/view/widgets/tag_selector.dart index 71c15b0..eee3cf6 100644 --- a/src/lib/features/tasks/view/widgets/tag_selector.dart +++ b/src/lib/features/tasks/view/widgets/tag_selector.dart @@ -1,237 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../../model/task_model.dart'; -import '../../viewmodel/task_viewmodel.dart'; +import 'package:task_management_app/features/tag/view/widgets/tag_selector.dart' + as tag_feature; -class TagSelector extends StatefulWidget { +class TagSelector extends StatelessWidget { const TagSelector({super.key}); - @override - State createState() => _TagSelectorState(); -} - -class _TagSelectorState extends State { - final TextEditingController _customController = TextEditingController(); - - @override - void dispose() { - _customController.dispose(); - super.dispose(); - } - - void _showAddCustomDialog(BuildContext context, TaskViewModel viewModel) { - _customController.clear(); - showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text('Tạo tag mới'), - content: TextField( - controller: _customController, - maxLength: 12, - decoration: const InputDecoration( - hintText: 'Tên tag (tối đa 12 ký tự)', - border: OutlineInputBorder(), - ), - autofocus: true, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Huỷ'), - ), - ElevatedButton( - onPressed: () { - final error = viewModel.addCustomTag(_customController.text); - if (error != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(error), backgroundColor: Colors.red), - ); - } else { - Navigator.pop(context); - } - }, - child: const Text('Thêm'), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - final viewModel = context.watch(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Tags', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 12), - - // ─── Nhóm 1: Loại công việc ─────────────────────── - _buildTagGroup( - label: 'Loại công việc', - tags: viewModel.workTypeTags, - viewModel: viewModel, - ), - const SizedBox(height: 12), - - // ─── Nhóm 2: Thời gian ──────────────────────────── - _buildTagGroup( - label: 'Thời gian', - tags: viewModel.timeTags, - viewModel: viewModel, - ), - const SizedBox(height: 12), - - // ─── Nhóm 3: Trạng thái ─────────────────────────── - _buildTagGroup( - label: 'Trạng thái', - tags: viewModel.statusTags, - viewModel: viewModel, - ), - const SizedBox(height: 12), - - // ─── Nhóm 4: Custom ─────────────────────────────── - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Custom', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Colors.black54, - ), - ), - // Nút thêm tag mới - if (viewModel.customTags.length < 5) - GestureDetector( - onTap: () => _showAddCustomDialog(context, viewModel), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: const Color(0xFFF1F7FD), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.blue.shade200), - ), - child: const Row( - children: [ - Icon(Icons.add, size: 14, color: Colors.blue), - SizedBox(width: 3), - Text( - 'Tạo tag', - style: TextStyle(fontSize: 12, color: Colors.blue), - ), - ], - ), - ), - ), - ], - ), - const SizedBox(height: 8), - - // Hiển thị custom tags đã tạo - viewModel.customTags.isEmpty - ? const Text( - 'Chưa có tag custom. Nhấn "Tạo tag" để thêm.', - style: TextStyle(fontSize: 12, color: Colors.black38), - ) - : Wrap( - spacing: 8, - runSpacing: 8, - children: viewModel.customTags - .map( - (tag) => _TagChip( - tag: tag, - isSelected: viewModel.isTagSelected(tag), - onTap: () => viewModel.toggleTag(tag), - ), - ) - .toList(), - ), - ], - ); - } - - Widget _buildTagGroup({ - required String label, - required List tags, - required TaskViewModel viewModel, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Colors.black54, - ), - ), - const SizedBox(height: 6), - Wrap( - spacing: 8, - runSpacing: 8, - children: tags - .map( - (tag) => _TagChip( - tag: tag, - isSelected: viewModel.isTagSelected(tag), - onTap: () => viewModel.toggleTag(tag), - ), - ) - .toList(), - ), - ], - ); - } -} - -// ─── Tag Chip Widget ───────────────────────────────────────── -class _TagChip extends StatelessWidget { - final TagModel tag; - final bool isSelected; - final VoidCallback onTap; - - const _TagChip({ - required this.tag, - required this.isSelected, - required this.onTap, - }); - @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: BoxDecoration( - color: isSelected ? tag.color : tag.color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isSelected) ...[ - const Icon(Icons.check, color: Colors.white, size: 14), - const SizedBox(width: 4), - ], - Text( - tag.name, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: isSelected ? Colors.white : tag.color, - ), - ), - ], - ), - ), - ); + return const tag_feature.TagSelector(); } } diff --git a/src/lib/features/tasks/viewmodel/task_viewmodel.dart b/src/lib/features/tasks/viewmodel/task_viewmodel.dart index 347d2bc..c943500 100644 --- a/src/lib/features/tasks/viewmodel/task_viewmodel.dart +++ b/src/lib/features/tasks/viewmodel/task_viewmodel.dart @@ -1,143 +1,21 @@ -import 'dart:convert'; import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:task_management_app/features/tag/model/tag_model.dart'; + import '../model/task_model.dart'; class TaskViewModel extends ChangeNotifier { - final List workTypeTags = [ - TagModel(id: 'work', name: 'Work', color: const Color(0xFF2196F3)), - TagModel(id: 'study', name: 'Study', color: const Color(0xFF9C27B0)), - TagModel(id: 'personal', name: 'Personal', color: const Color(0xFF4CAF50)), - TagModel(id: 'project', name: 'Project', color: const Color(0xFFFF9800)), - ]; - - final List timeTags = [ - TagModel(id: 'today', name: 'Today', color: const Color(0xFF00BCD4)), - TagModel(id: 'tomorrow', name: 'Tomorrow', color: const Color(0xFF3F51B5)), - TagModel( - id: 'this_week', - name: 'This Week', - color: const Color(0xFF009688), - ), - TagModel(id: 'later', name: 'Later', color: const Color(0xFF607D8B)), - ]; - - final List statusTags = [ - TagModel(id: 'pending', name: 'Pending', color: const Color(0xFFFF9800)), - TagModel( - id: 'in_progress', - name: 'In Progress', - color: const Color(0xFF2196F3), - ), - TagModel( - id: 'completed', - name: 'Completed', - color: const Color(0xFF4CAF50), - ), - TagModel( - id: 'cancelled', - name: 'Cancelled', - color: const Color(0xFF9E9E9E), - ), - ]; - - // ─── Custom Tags (lưu SharedPreferences) ──────────────── - List _customTags = []; - List get customTags => List.unmodifiable(_customTags); - - static const _customTagsKey = 'custom_tags'; - static const _maxCustomTags = 5; - static const _maxCustomTagLength = 12; - - TaskViewModel() { - _loadCustomTags(); - } - - Future _loadCustomTags() async { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_customTagsKey); - if (raw != null) { - final List decoded = jsonDecode(raw); - _customTags = decoded - .map( - (e) => TagModel( - id: e['id'], - name: e['name'], - color: Color(e['color']), - ), - ) - .toList(); - notifyListeners(); - } - } - - Future _saveCustomTags() async { - final prefs = await SharedPreferences.getInstance(); - final encoded = jsonEncode( - _customTags - .map((t) => {'id': t.id, 'name': t.name, 'color': t.color.toARGB32()}) - .toList(), - ); - await prefs.setString(_customTagsKey, encoded); - } - - // Trả về lỗi nếu có, null nếu thành công - String? addCustomTag(String name) { - name = name.trim(); - if (name.isEmpty) return 'Tên tag không được để trống'; - if (name.length > _maxCustomTagLength) - return 'Tối đa $_maxCustomTagLength ký tự'; - if (_customTags.length >= _maxCustomTags) - return 'Tối đa $_maxCustomTags tag custom'; - if (_customTags.any((t) => t.name.toLowerCase() == name.toLowerCase())) { - return 'Tag đã tồn tại'; - } - _customTags.add( - TagModel( - id: 'custom_${DateTime.now().millisecondsSinceEpoch}', - name: name, - color: _customTagColors[_customTags.length % _customTagColors.length], - ), - ); - _saveCustomTags(); - notifyListeners(); - return null; - } - - final List _customTagColors = const [ - Color(0xFFE91E63), - Color(0xFF673AB7), - Color(0xFF795548), - Color(0xFF009688), - Color(0xFFFF5722), - ]; - // ─── State tạo task ───────────────────────────────────── Priority _selectedPriority = Priority.medium; - final List _selectedTags = []; Priority get selectedPriority => _selectedPriority; - List get selectedTags => List.unmodifiable(_selectedTags); void setPriority(Priority priority) { _selectedPriority = priority; notifyListeners(); } - void toggleTag(TagModel tag) { - if (_selectedTags.any((t) => t.id == tag.id)) { - _selectedTags.removeWhere((t) => t.id == tag.id); - } else { - _selectedTags.add(tag); - } - notifyListeners(); - } - - bool isTagSelected(TagModel tag) => _selectedTags.any((t) => t.id == tag.id); - void reset() { _selectedPriority = Priority.medium; - _selectedTags.clear(); notifyListeners(); } @@ -189,7 +67,7 @@ class TaskViewModel extends ChangeNotifier { } if (_filterTagId != null) { result = result - .where((t) => t.tags.any((tag) => tag.id == _filterTagId)) + .where((t) => t.tags.any((tag) => tag.id.toString() == _filterTagId)) .toList(); } diff --git a/src/lib/features/user/model/user_profile_model.dart b/src/lib/features/user/model/user_profile_model.dart index d378b4b..6f0fd50 100644 --- a/src/lib/features/user/model/user_profile_model.dart +++ b/src/lib/features/user/model/user_profile_model.dart @@ -6,6 +6,7 @@ class UserProfileModel { final String avatarUrl; bool isNotificationEnabled; String appearance; + final Map heatmapData; UserProfileModel({ required this.id, @@ -15,6 +16,7 @@ class UserProfileModel { required this.avatarUrl, this.isNotificationEnabled = true, this.appearance = 'Light', + required this.heatmapData, }); factory UserProfileModel.fromJson(Map json) { @@ -25,25 +27,44 @@ class UserProfileModel { return 0; } + Map parseHeatmap = {}; + if (json['heatmapData'] != null) { + final Map rawHeatmap = json['heatmapData']; + rawHeatmap.forEach((dateString, count) { + try { + parseHeatmap[DateTime.parse(dateString)] = parseInt(count); + } catch (e) { + // skip error + } + }); + } return UserProfileModel( id: json['id'] as String? ?? '', name: (json['name'] ?? json['full_name'] ?? json['username']) as String? ?? - 'Unknown User', + 'Unknown User', tasksDone: parseInt(json['tasksDone'] ?? json['tasks_done']), streaks: parseInt(json['streaks'] ?? json['streak_count']), avatarUrl: - (json['avatarUrl'] ?? json['avatar_url'] ?? json['avatar']) as String? ?? - '', + (json['avatarUrl'] ?? json['avatar_url'] ?? json['avatar']) + as String? ?? + '', isNotificationEnabled: - (json['isNotificationEnabled'] ?? json['is_notification_enabled']) as bool? ?? - true, - appearance: (json['appearance'] ?? json['theme_mode']) as String? ?? 'Light', + (json['isNotificationEnabled'] ?? json['is_notification_enabled']) + as bool? ?? + true, + appearance: + (json['appearance'] ?? json['theme_mode']) as String? ?? 'Light', + + heatmapData: parseHeatmap, ); } - Map toJson() { + Map heatmapJson = {}; + heatmapData.forEach((date, count) { + heatmapJson[date.toIso8601String().split('T').first] = count; + }); return { 'id': id, 'name': name, @@ -52,6 +73,7 @@ class UserProfileModel { 'avatarUrl': avatarUrl, 'isNotificationEnabled': isNotificationEnabled, 'appearance': appearance, + 'heatmapData': heatmapJson, }; } @@ -63,6 +85,7 @@ class UserProfileModel { String? avatarUrl, bool? isNotificationEnabled, String? appearance, + Map? heatmapData, }) { return UserProfileModel( id: id ?? this.id, @@ -70,8 +93,10 @@ class UserProfileModel { tasksDone: tasksDone ?? this.tasksDone, streaks: streaks ?? this.streaks, avatarUrl: avatarUrl ?? this.avatarUrl, - isNotificationEnabled: isNotificationEnabled ?? this.isNotificationEnabled, + isNotificationEnabled: + isNotificationEnabled ?? this.isNotificationEnabled, appearance: appearance ?? this.appearance, + heatmapData: heatmapData ?? this.heatmapData, ); } -} \ No newline at end of file +} diff --git a/src/lib/features/user/service/user_service.dart b/src/lib/features/user/service/user_service.dart index 9a6c0d7..c38a2db 100644 --- a/src/lib/features/user/service/user_service.dart +++ b/src/lib/features/user/service/user_service.dart @@ -8,7 +8,14 @@ class UserService { Future fetchUserProfile() async { // Mimic API call delay for smooth state switching try{ + final user = _supabase.auth.currentUser; + if (user == null) { + throw Exception("Không tìm thấy phiên đăng nhập. Hãy đăng nhập lại"); + } final response = await _supabase.rpc('get_user_profile_stats'); + if(response == null){ + throw Exception("Không thể lấy thông tin người dùng. Hãy thử lại sau"); + } response['id'] = _supabase.auth.currentUser!.id; return UserProfileModel.fromJson(response); } diff --git a/src/lib/features/user/view/user_profile_view.dart b/src/lib/features/user/view/user_profile_view.dart index 2e71926..1e57bd2 100644 --- a/src/lib/features/user/view/user_profile_view.dart +++ b/src/lib/features/user/view/user_profile_view.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:flutter_heatmap_calendar/flutter_heatmap_calendar.dart'; import 'package:provider/provider.dart'; + import '../viewmodel/user_profile_viewmodel.dart'; +import 'widgets/logout_button.dart'; import 'widgets/profile_header.dart'; -import 'widgets/stat_card.dart'; -import 'widgets/settings_section.dart'; import 'widgets/settings_list_tile.dart'; -import 'widgets/logout_button.dart'; +import 'widgets/settings_section.dart'; +import 'widgets/stat_card.dart'; class UserProfileView extends StatelessWidget { const UserProfileView({super.key}); @@ -44,16 +46,16 @@ class UserProfileView extends StatelessWidget { duration: const Duration(milliseconds: 300), child: vm.isLoading ? Center( - key: ValueKey('loading'), - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.primary, - ), - ) + key: const ValueKey('loading'), + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), + ) : vm.user == null ? const Center( - key: ValueKey('error'), - child: Text("Error loading profile"), - ) + key: ValueKey('error'), + child: Text("Error loading profile"), + ) : Builder( builder: (innerContext) { vm.syncThemeWithProfile(innerContext); @@ -93,7 +95,7 @@ class UserProfileView extends StatelessWidget { child: StatCard( value: user.streaks.toString(), label: 'Streaks', - onTap: () {}, + onTap: () => _showHeatmapBottomSheet(context, vm), ), ), ], @@ -113,8 +115,9 @@ class UserProfileView extends StatelessWidget { value: user.isNotificationEnabled, activeColor: Theme.of(context).colorScheme.surface, activeTrackColor: Theme.of(context).colorScheme.primary, - inactiveThumbColor: - Theme.of(context).colorScheme.onSurfaceVariant, + inactiveThumbColor: Theme.of( + context, + ).colorScheme.onSurfaceVariant, inactiveTrackColor: Theme.of(context).colorScheme.outline, onChanged: (val) => vm.toggleNotification(val), ), @@ -153,4 +156,91 @@ class UserProfileView extends StatelessWidget { ), ); } -} \ No newline at end of file + + void _showHeatmapBottomSheet(BuildContext context, UserProfileViewModel vm) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (bottomSheetContext) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.outline, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 24), + + Text( + 'Bản đồ hoạt động', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + 'Giữ vững phong độ nhé! 🔥', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + + Center( + child: HeatMap( + datasets: vm.user!.heatmapData, + colorMode: ColorMode.opacity, + showText: false, + scrollable: true, + size: 30, + + colorsets: { + 1: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.2), + 3: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.4), + 5: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.6), + 7: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.8), + 9: Theme.of(context).colorScheme.primary, + }, + onClick: (value) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Đã hoàn thành $value công việc'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ); + }, + ); + } +} diff --git a/src/lib/features/user/viewmodel/user_profile_viewmodel.dart b/src/lib/features/user/viewmodel/user_profile_viewmodel.dart index 21b28c0..df62fcd 100644 --- a/src/lib/features/user/viewmodel/user_profile_viewmodel.dart +++ b/src/lib/features/user/viewmodel/user_profile_viewmodel.dart @@ -36,7 +36,12 @@ class UserProfileViewModel extends ChangeNotifier { _lastAppliedAppearance = null; } catch (e) { debugPrint("Error loading profile: $e"); - _user = _buildMockUser(); + + if (useMockData) { + _user = _buildMockUser(); + } else { + _user = null; + } } finally { _isLoading = false; notifyListeners(); @@ -44,15 +49,26 @@ class UserProfileViewModel extends ChangeNotifier { } UserProfileModel _buildMockUser() { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + + final Map mockHeatmapData = { + today.subtract(const Duration(days: 1)): 3, + today.subtract(const Duration(days: 2)): 7, + today.subtract(const Duration(days: 3)): 4, + today.subtract(const Duration(days: 4)): 8, + today.subtract(const Duration(days: 5)): 2, + today.subtract(const Duration(days: 8)): 5, + }; return UserProfileModel( id: 'mock-user-001', name: 'Alex Thompson', - // Valid URL so profile header can test normal network-avatar path. avatarUrl: 'https://i.pravatar.cc/300?img=12', appearance: 'Dark', tasksDone: 24, streaks: 12, isNotificationEnabled: true, + heatmapData: mockHeatmapData, ); } @@ -64,7 +80,6 @@ class UserProfileViewModel extends ChangeNotifier { } } - void updateAppearance(BuildContext context, String newAppearance) { if (_user != null) { _user!.appearance = newAppearance; @@ -72,7 +87,7 @@ class UserProfileViewModel extends ChangeNotifier { notifyListeners(); if (context.mounted) { - context.read().updateTheme(newAppearance); + context.read().updateTheme(newAppearance); } } } @@ -94,17 +109,16 @@ class UserProfileViewModel extends ChangeNotifier { if (context.mounted) { Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute(builder: (context) => const AuthGate()), - (route) => false, + (route) => false, ); } - } catch (e) { debugPrint("Lỗi đăng xuất: $e"); if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Lỗi đăng xuất: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Lỗi đăng xuất: $e'))); } } } -} \ No newline at end of file +} diff --git a/src/lib/main.dart b/src/lib/main.dart index 1ab3077..3be01b7 100644 --- a/src/lib/main.dart +++ b/src/lib/main.dart @@ -2,7 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:task_management_app/features/auth/presentation/view/auth_gate.dart'; +import 'package:task_management_app/features/auth/presentation/view/login_view.dart'; +import 'package:task_management_app/features/category/viewmodel/category_viewmodel.dart'; import 'package:task_management_app/features/main/view/screens/main_screen.dart'; +import 'package:task_management_app/features/tag/viewmodel/tag_viewmodel.dart'; import 'package:task_management_app/features/tasks/viewmodel/task_viewmodel.dart'; import 'core/theme/app_theme.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -35,6 +38,12 @@ Future main() async { ChangeNotifierProvider( create: (_) => TaskViewModel(), ), + ChangeNotifierProvider( + create: (_) => CategoryViewModel(), + ), + ChangeNotifierProvider( + create: (_) => TagViewModel(), + ), ], child: const TaskApp())); } diff --git a/src/pubspec.lock b/src/pubspec.lock index 7a21b3a..72a5d4a 100644 --- a/src/pubspec.lock +++ b/src/pubspec.lock @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -198,6 +198,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_heatmap_calendar: + dependency: "direct main" + description: + name: flutter_heatmap_calendar + sha256: "0933071844cd604b938e38358984244292ba1ba8f4b4b6f0f091f5927a23e958" + url: "https://pub.dev" + source: hosted + version: "1.0.5" flutter_lints: dependency: "direct dev" description: @@ -256,6 +264,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_generative_ai: + dependency: "direct main" + description: + name: google_generative_ai + sha256: "71f613d0247968992ad87a0eb21650a566869757442ba55a31a81be6746e0d1f" + url: "https://pub.dev" + source: hosted + version: "0.4.7" gotrue: dependency: transitive description: @@ -420,18 +436,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -729,10 +745,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" typed_data: dependency: transitive description: diff --git a/src/pubspec.yaml b/src/pubspec.yaml index fa7e071..9afc694 100644 --- a/src/pubspec.yaml +++ b/src/pubspec.yaml @@ -41,7 +41,9 @@ dependencies: provider: ^6.1.5+1 flutter_ringtone_player: ^4.0.0+4 image_picker: ^1.2.1 - shared_preferences: ^2.5.5 + shared_preferences: ^2.2.2 + flutter_heatmap_calendar: ^1.0.5 + google_generative_ai: ^0.4.7 dev_dependencies: flutter_test: diff --git a/supabase/migrations/20260409084009_create_user_profile_rpc.sql b/supabase/migrations/20260409084009_create_user_profile_rpc.sql index 235c129..cfc71d1 100644 --- a/supabase/migrations/20260409084009_create_user_profile_rpc.sql +++ b/supabase/migrations/20260409084009_create_user_profile_rpc.sql @@ -1,7 +1,7 @@ CREATE OR REPLACE FUNCTION get_user_profile_stats() RETURNS JSON LANGUAGE plpgsql -SECURITY INVOKER -- Chạy với quyền của user hiện tại (Tôn trọng RLS) +SECURITY INVOKER AS $$ DECLARE v_user_id UUID; diff --git a/supabase/migrations/20260413084521_update_user_profile_with_heatmap_rpc.sql b/supabase/migrations/20260413084521_update_user_profile_with_heatmap_rpc.sql new file mode 100644 index 0000000..aa82198 --- /dev/null +++ b/supabase/migrations/20260413084521_update_user_profile_with_heatmap_rpc.sql @@ -0,0 +1,66 @@ +CREATE OR REPLACE FUNCTION get_user_profile_stats(p_days INT DEFAULT 90) +RETURNS JSON +LANGUAGE plpgsql +SECURITY INVOKER +AS $$ +DECLARE + v_user_id UUID; + v_username TEXT; + v_avatar TEXT; + v_tasks_done INT; + v_current_streak INT; + v_heatmap_data JSON; +BEGIN + v_user_id := auth.uid(); + + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'User not authenticated'; + END IF; + + SELECT username, avatar INTO v_username, v_avatar + FROM public.profile + WHERE id = v_user_id; + + SELECT COUNT(*) INTO v_tasks_done + FROM public.task + WHERE profile_id = v_user_id AND status = 1; + + -- Get task done per day for heatmap + SELECT json_object_agg(task_date::TEXT, task_count) INTO v_heatmap_data + FROM ( + SELECT DATE(updated_at AT TIME ZONE 'Asia/Ho_Chi_Minh') AS task_date, COUNT(*) AS task_count + FROM public.task + WHERE profile_id = v_user_id AND status = 1 + AND updated_at >= ((CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Ho_Chi_Minh')::DATE - (p_days || ' days')::INTERVAL) + GROUP BY DATE(updated_at AT TIME ZONE 'Asia/Ho_Chi_Minh') + ) t; + + WITH completed_dates AS ( + SELECT DISTINCT DATE(updated_at AT TIME ZONE 'Asia/Ho_Chi_Minh') AS task_date + FROM public.task + WHERE profile_id = v_user_id AND status = 1 + ), + streak_groups AS ( + SELECT task_date, + task_date - (ROW_NUMBER() OVER (ORDER BY task_date))::INT AS grp + FROM completed_dates + ), + streak_counts AS ( + SELECT grp, MAX(task_date) as end_date, COUNT(*) as streak_length + FROM streak_groups + GROUP BY grp + ) + SELECT COALESCE(MAX(streak_length), 0) INTO v_current_streak + FROM streak_counts + WHERE end_date >= ((CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Ho_Chi_Minh')::DATE - INTERVAL '1 day'); + + + RETURN json_build_object( + 'name', COALESCE(v_username, 'Unknown User'), + 'avatarUrl', COALESCE(v_avatar, ''), + 'tasksDone', COALESCE(v_tasks_done, 0), + 'streaks', COALESCE(v_current_streak, 0), + 'heatmapData', COALESCE(v_heatmap_data, '{}'::JSON) + ); +END; +$$; \ No newline at end of file diff --git a/supabase/migrations/20260417060333_chatbot_add_task_rpc.sql b/supabase/migrations/20260417060333_chatbot_add_task_rpc.sql new file mode 100644 index 0000000..197e189 --- /dev/null +++ b/supabase/migrations/20260417060333_chatbot_add_task_rpc.sql @@ -0,0 +1,52 @@ +CREATE OR REPLACE FUNCTION create_task_full( + p_title TEXT, + p_priority INT4, + p_profile_id UUID, + p_tag_names TEXT[] +) +RETURNS JSON +LANGUAGE plpgsql +AS $$ +DECLARE + v_task_id INT8; + v_tag_name TEXT; + v_tag_id INT8; +BEGIN + + INSERT INTO task (title, priority, profile_id, status) + VALUES (p_title, p_priority, p_profile_id, 0) + RETURNING id INTO v_task_id; + + IF p_tag_names IS NOT NULL THEN + FOREACH v_tag_name IN ARRAY p_tag_names + LOOP + v_tag_name := trim(v_tag_name); + + IF v_tag_name != '' THEN + INSERT INTO tag (name, profile_id, color_code) + VALUES (v_tag_name, p_profile_id, '#6200EE') + ON CONFLICT (name, profile_id) + DO UPDATE SET name = EXCLUDED.name + RETURNING id INTO v_tag_id; + + + INSERT INTO task_tags (task_id, tag_id) + VALUES (v_task_id, v_tag_id) + ON CONFLICT DO NOTHING; + END IF; + END LOOP; + END IF; + + RETURN json_build_object( + 'success', true, + 'task_id', v_task_id, + 'message', 'Đã tạo task với priority ' || p_priority + ); + +EXCEPTION WHEN OTHERS THEN + RETURN json_build_object( + 'success', false, + 'error', SQLERRM + ); +END; +$$; \ No newline at end of file