diff --git a/README.md b/README.md index a4e4753..2cd5439 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,11 @@ -# Task Management App - Life Organizer +## 📁 Chi tiết các file thay đổi -## 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). +* **`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. -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 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 43ad594..311fd00 100644 --- a/src/lib/features/tasks/view/screens/create_task_screen.dart +++ b/src/lib/features/tasks/view/screens/create_task_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 '../../../../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}); @@ -11,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); @@ -28,15 +38,20 @@ class _CreateTaskScreenState extends State { body: SafeArea( child: Column( children: [ + // ─── Header ─────────────────────────────────────── Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, 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), @@ -61,17 +76,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, @@ -79,7 +110,12 @@ 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), @@ -112,20 +148,38 @@ 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, @@ -146,7 +200,7 @@ class _CreateTaskScreenState extends State { ) ], ), - ) + ), ], ), Container( @@ -160,15 +214,24 @@ class _CreateTaskScreenState extends State { ], ), 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), + ), ], ), ), @@ -177,27 +240,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: Theme.of(context).colorScheme.primary, 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)), ), ), ], @@ -209,4 +318,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 0232c40..882458d 100644 --- a/src/lib/features/tasks/view/screens/home_screen.dart +++ b/src/lib/features/tasks/view/screens/home_screen.dart @@ -1,6 +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'; @@ -10,46 +13,41 @@ class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { String formattedDate = DateFormat('EEEE, d MMMM').format(DateTime.now()); + final viewModel = context.watch(); final isDark = Theme.of(context).brightness == Brightness.dark; - // --- 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, + top: 0, + left: 0, + right: 0, + height: 250, child: ClipPath( clipper: TopWaveClipper(), - child: Container(color: Theme.of(context).colorScheme.primary), + 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: [ @@ -70,7 +68,9 @@ class HomeScreen extends StatelessWidget { 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( @@ -87,7 +87,8 @@ class HomeScreen extends StatelessWidget { ], ), ), - const SizedBox(height: 10), + + // ─── Date Card ──────────────────────────────── Container( width: double.infinity, margin: const EdgeInsets.symmetric(horizontal: 20), @@ -103,7 +104,10 @@ class HomeScreen extends StatelessWidget { 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, @@ -125,8 +129,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, + ); }, ), ), @@ -134,11 +143,40 @@ 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: [ + // 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, // --- SỬ DỤNG MOCK DATA VÀO TASKCARD --- TaskCard( task: task1, // Truyền task1 vào đây @@ -161,30 +199,138 @@ class HomeScreen extends StatelessWidget { color: Theme.of(context).colorScheme.primary, ), ), - ) - ], + 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: isDark - ? Theme.of(context).colorScheme.surfaceContainerHighest - : const Color(0xFFF1F7FD), - borderRadius: BorderRadius.circular(15), - ), - child: Icon( - Icons.call_outlined, - color: Theme.of(context).colorScheme.primary, + 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(), + ), + ), ], ), ), @@ -192,4 +338,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 bcdb6be..c97c2fc 100644 --- a/src/lib/main.dart +++ b/src/lib/main.dart @@ -7,6 +7,8 @@ import 'package:task_management_app/features/main/view/screens/main_screen.dart' import 'core/theme/app_theme.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'; import 'core/theme/theme_provider.dart'; @@ -14,6 +16,7 @@ import 'core/theme/theme_provider.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'] ?? ''; @@ -23,11 +26,7 @@ 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)); @@ -39,11 +38,8 @@ Future main() async { child: 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}); @@ -59,4 +55,4 @@ class TaskApp extends StatelessWidget { debugShowCheckedModeBanner: false, ); } -} \ No newline at end of file +}