From 4af552b519a5371dfc9580049099a5bd5d6c8c73 Mon Sep 17 00:00:00 2001 From: Nguyen Anh Kiet Date: Thu, 9 Apr 2026 19:03:15 +0700 Subject: [PATCH 1/4] feat(task): implement priority and tag selection features in task creation --- src/lib/features/tasks/model/task_model.dart | 62 ++- .../view/screens/create_task_screen.dart | 208 ++++++++-- .../tasks/view/screens/home_screen.dart | 368 +++++++++++++++--- .../tasks/view/widgets/priority_selector.dart | 65 ++++ .../tasks/view/widgets/tag_selector.dart | 60 +++ .../tasks/viewmodel/task_viewmodel.dart | 106 +++++ src/lib/main.dart | 58 +-- 7 files changed, 803 insertions(+), 124 deletions(-) create mode 100644 src/lib/features/tasks/view/widgets/priority_selector.dart create mode 100644 src/lib/features/tasks/view/widgets/tag_selector.dart create mode 100644 src/lib/features/tasks/viewmodel/task_viewmodel.dart diff --git a/src/lib/features/tasks/model/task_model.dart b/src/lib/features/tasks/model/task_model.dart index 8158b08..a6a64fb 100644 --- a/src/lib/features/tasks/model/task_model.dart +++ b/src/lib/features/tasks/model/task_model.dart @@ -1,13 +1,69 @@ import 'package:flutter/material.dart'; +// ─── Priority Enum ─────────────────────────────────────────── +enum Priority { low, medium, high, urgent } + +extension PriorityExtension on Priority { + String get label { + switch (this) { + case Priority.low: + return 'Low'; + case Priority.medium: + return 'Medium'; + case Priority.high: + return 'High'; + case Priority.urgent: + return 'Urgent'; + } + } + + Color get color { + switch (this) { + case Priority.low: + return const Color(0xFF4CAF50); // xanh lá + case Priority.medium: + return const Color(0xFF2196F3); // xanh dương + case Priority.high: + return const Color(0xFFFF9800); // cam + case Priority.urgent: + return const Color(0xFFF44336); // đỏ + } + } + + IconData get icon { + switch (this) { + case Priority.low: + return Icons.flag_outlined; + case Priority.medium: + return Icons.flag; + case Priority.high: + return Icons.flag; + case Priority.urgent: + return Icons.flag; + } + } +} + +// ─── 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; // ID duy nhất để làm Hero tag và gọi API sau này + final String id; String title; String description; String category; TimeOfDay startTime; TimeOfDay endTime; DateTime date; + Priority priority; + List tags; TaskModel({ required this.id, @@ -17,5 +73,7 @@ class TaskModel { required this.startTime, required this.endTime, required this.date, + this.priority = Priority.medium, + this.tags = const [], }); -} \ No newline at end of file +} 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 a7f22c9..949db60 100644 --- a/src/lib/features/tasks/view/screens/create_task_screen.dart +++ b/src/lib/features/tasks/view/screens/create_task_screen.dart @@ -1,8 +1,13 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; import '../../../../core/theme/app_colors.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}); @@ -12,8 +17,12 @@ class CreateTaskScreen extends StatefulWidget { } class _CreateTaskScreenState extends State { - final TextEditingController _nameController = TextEditingController(text: 'Team Meeting'); - final TextEditingController _descController = TextEditingController(text: 'Discuss all questions about new projects'); + final TextEditingController _nameController = TextEditingController( + text: 'Team Meeting', + ); + final TextEditingController _descController = TextEditingController( + text: 'Discuss all questions about new projects', + ); DateTime _selectedDate = DateTime.now(); TimeOfDay _startTime = const TimeOfDay(hour: 10, minute: 0); TimeOfDay _endTime = const TimeOfDay(hour: 11, minute: 0); @@ -27,15 +36,20 @@ class _CreateTaskScreenState extends State { body: SafeArea( child: Column( children: [ + // ─── Header ─────────────────────────────────────── Container( decoration: BoxDecoration( color: Colors.white, borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(30), bottomRight: Radius.circular(30)), + bottomLeft: Radius.circular(30), + bottomRight: Radius.circular(30), + ), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, offset: const Offset(0, 5)) + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 5), + ), ], ), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 15), @@ -43,7 +57,10 @@ class _CreateTaskScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ IconButton( - icon: const Icon(Icons.arrow_back_ios_new_rounded, color: Colors.black), + icon: const Icon( + Icons.arrow_back_ios_new_rounded, + color: Colors.black, + ), onPressed: () => Navigator.pop(context), ), const Icon(Icons.menu_rounded, color: Colors.black), @@ -51,17 +68,33 @@ class _CreateTaskScreenState extends State { ], ), ), + + // ─── Body ───────────────────────────────────────── Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(25.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Create New Task', style: Theme.of(context).textTheme.headlineMedium), + Text( + 'Create New Task', + style: Theme.of(context).textTheme.headlineMedium, + ), const SizedBox(height: 25), - CustomInputField(label: 'Task Name', hint: 'Enter task name', controller: _nameController), + + // Task Name + CustomInputField( + label: 'Task Name', + hint: 'Enter task name', + controller: _nameController, + ), const SizedBox(height: 20), - Text('Select Category', style: Theme.of(context).textTheme.labelLarge), + + // Category + Text( + 'Select Category', + style: Theme.of(context).textTheme.labelLarge, + ), const SizedBox(height: 10), SizedBox( height: 40, @@ -69,20 +102,38 @@ class _CreateTaskScreenState extends State { scrollDirection: Axis.horizontal, itemCount: 4, itemBuilder: (context, index) { - List categories = ['Development', 'Research', 'Design', 'Backend']; + 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), + onSelected: (selected) => setState( + () => _selectedCategoryIndex = selected + ? index + : 0, + ), backgroundColor: const Color(0xFFF1F7FD), selectedColor: AppColors.primaryBlue, - labelStyle: TextStyle(color: isSelected ? Colors.white : AppColors.primaryBlue, fontSize: 14), + labelStyle: TextStyle( + color: isSelected + ? Colors.white + : AppColors.primaryBlue, + fontSize: 14, + ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: const BorderSide(color: Color(0xFFF1F7FD), width: 1)), + borderRadius: BorderRadius.circular(10), + side: const BorderSide( + color: Color(0xFFF1F7FD), + width: 1, + ), + ), showCheckmark: false, ), ); @@ -90,49 +141,92 @@ class _CreateTaskScreenState extends State { ), ), const SizedBox(height: 20), + + // ─── PRIORITY SELECTOR (MỚI) ────────────── + const PrioritySelector(), + const SizedBox(height: 20), + + // ─── TAG SELECTOR (MỚI) ─────────────────── + const TagSelector(), + const SizedBox(height: 20), + + // Date Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Date', style: Theme.of(context).textTheme.labelLarge), + Text( + 'Date', + style: Theme.of(context).textTheme.labelLarge, + ), const SizedBox(height: 5), InkWell( onTap: () async { final DateTime? picked = await showDatePicker( - context: context, initialDate: _selectedDate, - firstDate: DateTime(2000), lastDate: DateTime(2100)); - if (picked != null) setState(() => _selectedDate = picked); + context: context, + initialDate: _selectedDate, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null) { + setState(() => _selectedDate = picked); + } }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(formattedDate, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + Text( + formattedDate, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), const SizedBox(height: 5), - Container(width: 150, height: 1, color: Colors.black26) + Container( + width: 150, + height: 1, + color: Colors.black26, + ), ], ), - ) + ), ], ), Container( padding: const EdgeInsets.all(12), - decoration: BoxDecoration(color: AppColors.primaryBlue, borderRadius: BorderRadius.circular(15)), - child: const Icon(Icons.date_range_rounded, color: Colors.white), - ) + decoration: BoxDecoration( + color: AppColors.primaryBlue, + borderRadius: BorderRadius.circular(15), + ), + child: const Icon( + Icons.date_range_rounded, + color: Colors.white, + ), + ), ], ), const SizedBox(height: 25), + + // Time Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Start time', style: Theme.of(context).textTheme.labelLarge), + Text( + 'Start time', + style: Theme.of(context).textTheme.labelLarge, + ), const SizedBox(height: 5), - TimePickerWidget(time: _startTime, onChanged: (newTime) => setState(() => _startTime = newTime)), + TimePickerWidget( + time: _startTime, + onChanged: (t) => + setState(() => _startTime = t), + ), ], ), ), @@ -141,27 +235,73 @@ class _CreateTaskScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('End time', style: Theme.of(context).textTheme.labelLarge), + Text( + 'End time', + style: Theme.of(context).textTheme.labelLarge, + ), const SizedBox(height: 5), - TimePickerWidget(time: _endTime, onChanged: (newTime) => setState(() => _endTime = newTime)), + TimePickerWidget( + time: _endTime, + onChanged: (t) => setState(() => _endTime = t), + ), ], ), ), ], ), const SizedBox(height: 25), - CustomInputField(label: 'Description', hint: 'Enter task description', controller: _descController, maxLines: 2), + + // Description + CustomInputField( + label: 'Description', + hint: 'Enter task description', + controller: _descController, + maxLines: 2, + ), const SizedBox(height: 40), + + // ─── Create Button ──────────────────────── Center( child: ElevatedButton( - onPressed: () {}, + onPressed: () { + final viewModel = context.read(); + final List categories = [ + 'Development', + 'Research', + 'Design', + 'Backend', + ]; + final newTask = TaskModel( + id: DateTime.now().millisecondsSinceEpoch + .toString(), + title: _nameController.text, + description: _descController.text, + category: categories[_selectedCategoryIndex], + startTime: _startTime, + endTime: _endTime, + date: _selectedDate, + priority: viewModel.selectedPriority, + tags: List.from(viewModel.selectedTags), + ); + viewModel.addTask(newTask); + viewModel.reset(); + Navigator.pop(context); + }, style: ElevatedButton.styleFrom( backgroundColor: AppColors.primaryBlue, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 100, vertical: 15), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), + padding: const EdgeInsets.symmetric( + horizontal: 100, + vertical: 15, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + ), + child: const Text( + 'Create Task', + style: TextStyle(fontSize: 18), ), - child: const Text('Create Task', style: TextStyle(fontSize: 18)), ), ), ], @@ -173,4 +313,4 @@ class _CreateTaskScreenState extends State { ), ); } -} \ No newline at end of file +} diff --git a/src/lib/features/tasks/view/screens/home_screen.dart b/src/lib/features/tasks/view/screens/home_screen.dart index c30697a..bc9876b 100644 --- a/src/lib/features/tasks/view/screens/home_screen.dart +++ b/src/lib/features/tasks/view/screens/home_screen.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; import '../../../../core/theme/app_colors.dart'; import '../../model/task_model.dart'; +import '../../viewmodel/task_viewmodel.dart'; import '../widgets/task_widgets.dart'; import 'create_task_screen.dart'; @@ -11,81 +13,108 @@ class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { String formattedDate = DateFormat('EEEE, d MMMM').format(DateTime.now()); + final viewModel = context.watch(); - // --- TẠO DỮ LIỆU GIẢ LẬP (MOCK DATA) --- - final task1 = TaskModel( - id: '1', // ID dùng để làm tag cho Hero Animation - title: 'Team Meeting', - description: 'Discuss all questions about new projects', - category: 'Development', - startTime: const TimeOfDay(hour: 10, minute: 0), - endTime: const TimeOfDay(hour: 11, minute: 0), - date: DateTime.now(), - ); - - final task2 = TaskModel( - id: '2', // ID phải khác nhau - title: 'Call the stylist', - description: 'Agree on an evening look', - category: 'Design', - startTime: const TimeOfDay(hour: 11, minute: 0), - endTime: const TimeOfDay(hour: 12, minute: 0), - date: DateTime.now(), - ); - // ---------------------------------------- + // Nhóm task theo priority + Map> grouped = {}; + for (var priority in Priority.values.reversed) { + final tasks = viewModel.tasks + .where((t) => t.priority == priority) + .toList(); + if (tasks.isNotEmpty) grouped[priority] = tasks; + } return Scaffold( body: Stack( children: [ Positioned( - top: 0, left: 0, right: 0, height: 250, - child: ClipPath(clipper: TopWaveClipper(), child: Container(color: AppColors.primaryBlue)), + top: 0, + left: 0, + right: 0, + height: 250, + child: ClipPath( + clipper: TopWaveClipper(), + child: Container(color: AppColors.primaryBlue), + ), ), SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // ─── Header ─────────────────────────────────── Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Icon(Icons.menu_rounded, color: Colors.black), Row( children: [ - const Icon(Icons.notifications_none_rounded, color: Colors.black), + const Icon( + Icons.notifications_none_rounded, + color: Colors.black, + ), const SizedBox(width: 15), const CircleAvatar( radius: 20, - backgroundImage: NetworkImage('https://i.pravatar.cc/150?u=user1'), + backgroundImage: NetworkImage( + 'https://i.pravatar.cc/150?u=user1', + ), ), const SizedBox(width: 10), IconButton( - icon: const Icon(Icons.add_rounded, color: Colors.black), - onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => const CreateTaskScreen())), + icon: const Icon( + Icons.add_rounded, + color: Colors.black, + ), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const CreateTaskScreen(), + ), + ), ), ], ), ], ), ), - const SizedBox(height: 10), + + // ─── Date Card ──────────────────────────────── Container( width: double.infinity, margin: const EdgeInsets.symmetric(horizontal: 20), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(30)), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(30), + ), child: Padding( padding: const EdgeInsets.all(25.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('My Task', style: Theme.of(context).textTheme.headlineMedium), + Text( + 'My Task', + style: Theme.of(context).textTheme.headlineMedium, + ), const SizedBox(height: 5), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Today', style: Theme.of(context).textTheme.titleMedium), - Text(formattedDate, style: const TextStyle(color: AppColors.grayText, fontSize: 14)), + Text( + 'Today', + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + formattedDate, + style: const TextStyle( + color: AppColors.grayText, + fontSize: 14, + ), + ), ], ), const SizedBox(height: 20), @@ -95,8 +124,13 @@ class HomeScreen extends StatelessWidget { scrollDirection: Axis.horizontal, itemCount: 10, itemBuilder: (context, index) { - DateTime date = DateTime.now().add(Duration(days: index)); - return DateBox(date: date, isSelected: index == 0); + DateTime date = DateTime.now().add( + Duration(days: index), + ); + return DateBox( + date: date, + isSelected: index == 0, + ); }, ), ), @@ -104,42 +138,173 @@ class HomeScreen extends StatelessWidget { ), ), ), - const SizedBox(height: 25), - Expanded( - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 20), + const SizedBox(height: 15), + + // ─── Filter Bar ─────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( children: [ - // --- SỬ DỤNG MOCK DATA VÀO TASKCARD --- - TaskCard( - task: task1, // Truyền task1 vào đây - leading: Stack( - children: [ - const CircleAvatar(radius: 15, backgroundImage: NetworkImage('https://i.pravatar.cc/150?u=user2')), - const Positioned(left: 10, child: CircleAvatar(radius: 15, backgroundImage: NetworkImage('https://i.pravatar.cc/150?u=user3'))), - const Positioned(left: 20, child: CircleAvatar(radius: 15, backgroundImage: NetworkImage('https://i.pravatar.cc/150?u=user4'))), - Positioned( - left: 30, - child: Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle), - child: const Icon(Icons.add_rounded, size: 20, color: AppColors.primaryBlue), + // Sort button + GestureDetector( + onTap: () => viewModel.toggleSortByPriority(), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 7, + ), + decoration: BoxDecoration( + color: viewModel.sortByPriority + ? AppColors.primaryBlue + : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppColors.primaryBlue, + width: 1, + ), + ), + child: Row( + children: [ + Icon( + Icons.sort, + size: 16, + color: viewModel.sortByPriority + ? Colors.white + : AppColors.primaryBlue, ), - ) - ], + const SizedBox(width: 5), + Text( + 'Sort', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: viewModel.sortByPriority + ? Colors.white + : AppColors.primaryBlue, + ), + ), + ], + ), ), ), - TaskCard( - task: task2, // Truyền task2 vào đây - leading: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration(color: const Color(0xFFF1F7FD), borderRadius: BorderRadius.circular(15)), - child: const Icon(Icons.call_outlined, color: AppColors.primaryBlue), + const SizedBox(width: 8), + + // Filter theo priority + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + // Chip "All" + _FilterChip( + label: 'All', + isSelected: viewModel.filterPriority == null, + color: AppColors.primaryBlue, + onTap: () => viewModel.setFilterPriority(null), + ), + const SizedBox(width: 8), + // Chip cho từng priority + ...Priority.values.map( + (p) => Padding( + padding: const EdgeInsets.only(right: 8), + child: _FilterChip( + label: p.label, + isSelected: viewModel.filterPriority == p, + color: p.color, + onTap: () => viewModel.setFilterPriority(p), + ), + ), + ), + ], + ), ), ), - // ---------------------------------------- ], ), ), + const SizedBox(height: 10), + + // ─── Task List nhóm theo Priority ───────────── + Expanded( + child: grouped.isEmpty + ? _buildEmptyState() + : ListView( + padding: const EdgeInsets.symmetric(horizontal: 20), + children: grouped.entries.map((entry) { + final priority = entry.key; + final tasks = entry.value; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header nhóm + Padding( + padding: const EdgeInsets.only( + bottom: 10, + top: 5, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 4, + height: 20, + decoration: BoxDecoration( + color: priority.color, + borderRadius: + BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + _priorityGroupLabel(priority), + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + Text( + '${tasks.length} task', + style: const TextStyle( + fontSize: 12, + color: AppColors.grayText, + ), + ), + ], + ), + ), + + // Danh sách task trong nhóm + ...tasks.map( + (task) => TaskCard( + task: task, + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: priority.color.withValues( + alpha: 0.1, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + priority.icon, + color: priority.color, + size: 22, + ), + ), + ), + ), + const SizedBox(height: 10), + ], + ); + }).toList(), + ), + ), ], ), ), @@ -147,4 +312,81 @@ class HomeScreen extends StatelessWidget { ), ); } -} \ No newline at end of file + + String _priorityGroupLabel(Priority priority) { + switch (priority) { + case Priority.urgent: + return 'Ưu tiên Khẩn cấp'; + case Priority.high: + return 'Ưu tiên Cao'; + case Priority.medium: + return 'Ưu tiên Trung bình'; + case Priority.low: + return 'Ưu tiên Thấp'; + } + } + + Widget _buildEmptyState() { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.checklist_rounded, size: 60, color: AppColors.grayText), + SizedBox(height: 12), + Text( + 'Chưa có task nào', + style: TextStyle( + fontSize: 16, + color: AppColors.grayText, + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: 4), + Text( + 'Nhấn + để tạo task mới', + style: TextStyle(fontSize: 13, color: AppColors.grayText), + ), + ], + ), + ); + } +} + +// ─── Filter Chip Widget ────────────────────────────────────── +class _FilterChip extends StatelessWidget { + final String label; + final bool isSelected; + final Color color; + final VoidCallback onTap; + + const _FilterChip({ + required this.label, + required this.isSelected, + required this.color, + 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: 7), + decoration: BoxDecoration( + color: isSelected ? color : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color, width: 1), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isSelected ? Colors.white : color, + ), + ), + ), + ); + } +} diff --git a/src/lib/features/tasks/view/widgets/priority_selector.dart b/src/lib/features/tasks/view/widgets/priority_selector.dart new file mode 100644 index 0000000..3beee4b --- /dev/null +++ b/src/lib/features/tasks/view/widgets/priority_selector.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../model/task_model.dart'; +import '../../viewmodel/task_viewmodel.dart'; + +class PrioritySelector extends StatelessWidget { + const PrioritySelector({super.key}); + + @override + Widget build(BuildContext context) { + final viewModel = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Priority', style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 10), + Row( + children: Priority.values.map((priority) { + final isSelected = viewModel.selectedPriority == priority; + return Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () => viewModel.setPriority(priority), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected + ? priority.color + : const Color(0xFFF1F7FD), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? priority.color : Colors.transparent, + ), + ), + child: Column( + children: [ + Icon( + priority.icon, + color: isSelected ? Colors.white : priority.color, + size: 20, + ), + const SizedBox(height: 4), + Text( + priority.label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: isSelected ? Colors.white : priority.color, + ), + ), + ], + ), + ), + ), + ), + ); + }).toList(), + ), + ], + ); + } +} diff --git a/src/lib/features/tasks/view/widgets/tag_selector.dart b/src/lib/features/tasks/view/widgets/tag_selector.dart new file mode 100644 index 0000000..0ac54be --- /dev/null +++ b/src/lib/features/tasks/view/widgets/tag_selector.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../viewmodel/task_viewmodel.dart'; + +class TagSelector extends StatelessWidget { + const TagSelector({super.key}); + + @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: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: viewModel.availableTags.map((tag) { + final isSelected = viewModel.isTagSelected(tag); + return GestureDetector( + onTap: () => viewModel.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(), + ), + ], + ); + } +} diff --git a/src/lib/features/tasks/viewmodel/task_viewmodel.dart b/src/lib/features/tasks/viewmodel/task_viewmodel.dart new file mode 100644 index 0000000..0917473 --- /dev/null +++ b/src/lib/features/tasks/viewmodel/task_viewmodel.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import '../model/task_model.dart'; + +class TaskViewModel extends ChangeNotifier { + // ─── Danh sách tag có sẵn + final List availableTags = [ + 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)), + ]; + + // ─── State khi đang 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(); + } + + // ─── Danh sách task + filter/sort ──────────────────────── + final List _tasks = []; + + List get tasks => _getFilteredAndSorted(); + + Priority? _filterPriority; + String? _filterTagId; + bool _sortByPriority = false; + + Priority? get filterPriority => _filterPriority; + String? get filterTagId => _filterTagId; + bool get sortByPriority => _sortByPriority; + + void setFilterPriority(Priority? priority) { + _filterPriority = priority; + notifyListeners(); + } + + void setFilterTag(String? tagId) { + _filterTagId = tagId; + notifyListeners(); + } + + void toggleSortByPriority() { + _sortByPriority = !_sortByPriority; + notifyListeners(); + } + + void addTask(TaskModel task) { + _tasks.add(task); + notifyListeners(); + } + + List _getFilteredAndSorted() { + List result = List.from(_tasks); + + // Lọc theo priority + if (_filterPriority != null) { + result = result.where((t) => t.priority == _filterPriority).toList(); + } + + // Lọc theo tag + if (_filterTagId != null) { + result = result + .where((t) => t.tags.any((tag) => tag.id == _filterTagId)) + .toList(); + } + + // Sắp xếp theo priority (urgent → high → medium → low) + if (_sortByPriority) { + const order = [ + Priority.urgent, + Priority.high, + Priority.medium, + Priority.low, + ]; + result.sort( + (a, b) => + order.indexOf(a.priority).compareTo(order.indexOf(b.priority)), + ); + } + + return result; + } +} diff --git a/src/lib/main.dart b/src/lib/main.dart index e0f3857..13f7331 100644 --- a/src/lib/main.dart +++ b/src/lib/main.dart @@ -6,11 +6,13 @@ import 'features/auth/presentation/view/login_view.dart'; import 'features/auth/presentation/view/auth_gate.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; - +import 'package:provider/provider.dart'; +import 'features/tasks/viewmodel/task_viewmodel.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + // Tải cấu hình từ file .env await dotenv.load(fileName: ".env"); String supabaseUrl = dotenv.env['SUPABASE_URL'] ?? ''; @@ -20,43 +22,49 @@ Future main() async { debugPrint('Error: SUPABASE_URL or SUPABASE_ANON_KEY is missing'); } - // 3. Khởi tạo kết nối Supabase - await Supabase.initialize( - url: supabaseUrl, - anonKey: supabaseAnonKey, - ); + await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey); SystemChrome.setSystemUIOverlayStyle( - const SystemUiOverlayStyle(statusBarColor: Colors.transparent)); + const SystemUiOverlayStyle(statusBarColor: Colors.transparent), + ); + runApp(const TaskApp()); } -// 4. Create a global variable for ViewModel to call API quickly final supabase = Supabase.instance.client; - - class TaskApp extends StatelessWidget { const TaskApp({super.key}); @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Task Management App', - theme: ThemeData( - scaffoldBackgroundColor: AppColors.backgroundBlue, - primaryColor: AppColors.primaryBlue, - useMaterial3: true, - fontFamily: 'Montserrat', - textTheme: const TextTheme( - headlineMedium: TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Colors.black), - titleMedium: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black), - bodyMedium: TextStyle(fontSize: 14, color: AppColors.grayText), - labelLarge: TextStyle(fontSize: 16, color: AppColors.primaryBlue), + return ChangeNotifierProvider( + create: (_) => TaskViewModel(), + child: MaterialApp( + title: 'Task Management App', + theme: ThemeData( + scaffoldBackgroundColor: AppColors.backgroundBlue, + primaryColor: AppColors.primaryBlue, + useMaterial3: true, + fontFamily: 'Montserrat', + textTheme: const TextTheme( + headlineMedium: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + titleMedium: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + bodyMedium: TextStyle(fontSize: 14, color: AppColors.grayText), + labelLarge: TextStyle(fontSize: 16, color: AppColors.primaryBlue), + ), ), + home: const AuthGate(), + debugShowCheckedModeBanner: false, ), - home: const AuthGate(), - debugShowCheckedModeBanner: false, ); } -} \ No newline at end of file +} From ffd233a5c0189ad7a044ecb0924f7b9110edec0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Anh=20Ki=E1=BB=87t?= Date: Thu, 9 Apr 2026 19:06:34 +0700 Subject: [PATCH 2/4] Update README.md --- README.md | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index a4e4753..954edd3 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,11 @@ -# Task Management App - Life Organizer +Bao gồm các file sau -## 1. Introduction -This repository contains the source code for the **Task Management App (Life Organizer)**, developed as a project for the **SE346** course at the University of Information Technology - VNUHCM (UIT). +- File 1: task_model.dart — thêm Priority + TagModel +- File 2: task_viewmodel.dart — logic Provider +- File 3: priority_selector.dart — widget chọn priority +- File 4: tag_selector.dart — widget chọn tag +- File 5: create_task_screen.dart — ghép 2 widget vào form +- File 6: main.dart — đăng ký Provider +- File 7: home_screen.dart — hiển thị task nhóm theo priority -Going beyond a traditional to-do list, this application is designed to be a comprehensive personal assistant. It helps users easily organize various life contexts, such as tracking utility bills, managing grocery shopping lists, building daily habits, and handling household chores efficiently. - -## 2. Authors -This project is built and maintained by a dedicated team of 4 members: -* [Trần Quang Hạ](https://github.com/tqha1011) -* [Nguyễn Lê Hoàng Hảo](https://github.com/hoanghaoz) -* [Nguyễn Anh Kiệt](https://github.com/anhkietbienhoa-crypto) -* [Nguyễn Trí Kiệt](https://github.com/Ender-Via) - -## 3. Tech Stack -The application is built with a focus on performance, security, and clean code principles, strictly following the **Feature-Based MVVM** architecture: -* **Frontend:** Flutter -* **Backend & Database:** Supabase (PostgreSQL, Auth, Row Level Security) -* **Code Quality & CI/CD:** GitHub Actions, SonarCloud - -## 4. Documentation -For a deeper dive into our system design and development workflows, please explore the attached documentation: -* [Flutter App Architecture](documentation/architecture/flutter-architecture.md) -* [Database Schema & ERD](documentation/architecture/database-schema.md) -* [Git & Conventional Commits Guidelines](documentation/guidelines/conventional-commit.md) \ No newline at end of file +image From f9c33590646a8a963df6199bc68d6cb4cabebe30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Anh=20Ki=E1=BB=87t?= Date: Thu, 9 Apr 2026 20:00:22 +0700 Subject: [PATCH 3/4] Update README.md --- README.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 954edd3..6ec600d 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ -Bao gồm các file sau +## Chi tiết mã nguồn -- File 1: task_model.dart — thêm Priority + TagModel -- File 2: task_viewmodel.dart — logic Provider -- File 3: priority_selector.dart — widget chọn priority -- File 4: tag_selector.dart — widget chọn tag -- File 5: create_task_screen.dart — ghép 2 widget vào form -- File 6: main.dart — đăng ký Provider -- File 7: home_screen.dart — hiển thị task nhóm theo priority +| Tên file | Vai trò / Thay đổi chính | +| :--- | :--- | +| `task_model.dart` | Thêm thuộc tính Priority + `TagModel` | +| `task_viewmodel.dart` | Xử lý logic state management (Provider) | +| `priority_selector.dart` | Widget UI chọn mức độ ưu tiên | +| `tag_selector.dart` | Widget UI chọn tag (thời gian, trạng thái, custom) | +| `create_task_screen.dart` | Ghép 2 widget trên vào Form tạo task | +| `main.dart` | Đăng ký Provider khởi chạy ứng dụng | +| `home_screen.dart` | Hiển thị danh sách task phân nhóm theo Priority | image From 5c505c7f92bab473b9e782ba5b7bccff4449ad2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Anh=20Ki=E1=BB=87t?= Date: Thu, 9 Apr 2026 20:01:06 +0700 Subject: [PATCH 4/4] Update README.md --- README.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6ec600d..2cd5439 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,11 @@ -## Chi tiết mã nguồn +## 📁 Chi tiết các file thay đổi -| Tên file | Vai trò / Thay đổi chính | -| :--- | :--- | -| `task_model.dart` | Thêm thuộc tính Priority + `TagModel` | -| `task_viewmodel.dart` | Xử lý logic state management (Provider) | -| `priority_selector.dart` | Widget UI chọn mức độ ưu tiên | -| `tag_selector.dart` | Widget UI chọn tag (thời gian, trạng thái, custom) | -| `create_task_screen.dart` | Ghép 2 widget trên vào Form tạo task | -| `main.dart` | Đăng ký Provider khởi chạy ứng dụng | -| `home_screen.dart` | Hiển thị danh sách task phân nhóm theo Priority | +* **`task_model.dart`**: Thêm thuộc tính `Priority` và tích hợp `TagModel`. +* **`task_viewmodel.dart`**: Cập nhật logic xử lý trạng thái cho Provider. +* **`priority_selector.dart`**: Thêm UI Widget hỗ trợ chọn mức độ ưu tiên. +* **`tag_selector.dart`**: Thêm UI Widget hỗ trợ chọn các loại tag. +* **`create_task_screen.dart`**: Tích hợp 2 widget chọn Priority và Tag vào form tạo nhiệm vụ. +* **`main.dart`**: Đăng ký Provider mới cho hệ thống. +* **`home_screen.dart`**: Cập nhật giao diện, hiển thị danh sách task được nhóm theo mức độ ưu tiên. image