diff --git a/src/lib/features/main/view/screens/create_task.dart b/src/lib/features/main/view/screens/create_task.dart new file mode 100644 index 0000000..2da3d7d --- /dev/null +++ b/src/lib/features/main/view/screens/create_task.dart @@ -0,0 +1,337 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/theme/app_colors.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class CreateTaskProvider extends ChangeNotifier { + final _supabase = Supabase.instance.client; + + // Form state variables + String _taskName = ""; + String _selectedCategory = "Development"; + DateTime _selectedDate = DateTime.now(); + TimeOfDay _startTime = TimeOfDay(hour: 10, minute: 0); + TimeOfDay _endTime = TimeOfDay(hour: 11, minute: 0); + String _description = ""; + bool _isLoading = false; + + // Getters for UI consumption + String get taskName => _taskName; + String get selectedCategory => _selectedCategory; + DateTime get selectedDate => _selectedDate; + TimeOfDay get startTime => _startTime; + TimeOfDay get endTime => _endTime; + String get description => _description; + bool get isLoading => _isLoading; + + // State update methods + void setTaskName(String value) { _taskName = value; notifyListeners(); } + void setCategory(String value) { _selectedCategory = value; notifyListeners(); } + void setDate(DateTime value) { _selectedDate = value; notifyListeners(); } + void setStartTime(TimeOfDay value) { _startTime = value; notifyListeners(); } + void setEndTime(TimeOfDay value) { _endTime = value; notifyListeners(); } + void setDescription(String value) { _description = value; notifyListeners(); } + + /// Validates input and persists the new task to Supabase + Future onCreateTaskPressed(BuildContext context) async { + + if (_taskName.trim().isEmpty) { + _showSnackBar(context, "Task name is required."); + return; + } + + final user = _supabase.auth.currentUser; + if (user == null) { + _showSnackBar(context, "Session not found. Please re-authenticate."); + return; + } + + _isLoading = true; + notifyListeners(); + + try { + // Map category labels to specific database IDs based on the provided schema + final categoryMapping = { + "Development": 1, + "Research": 2, + "Design": 3, + "Backend": 4, + }; + int categoryId = categoryMapping[_selectedCategory] ?? 1; + + // Construct a unified timestamp from selected date and start time + final scheduledDateTime = DateTime( + _selectedDate.year, + _selectedDate.month, + _selectedDate.day, + _startTime.hour, + _startTime.minute, + ); + + // Execute insert operation. + // Note: 'description' is omitted as it does not exist in the current 'task' table schema. + await _supabase.from('task').insert({ + 'title': _taskName.trim(), + 'status': 0, + 'priority': 1, + 'profile_id': user.id, + 'category_id': categoryId, + 'create_at': scheduledDateTime.toIso8601String(), + }); + + if (context.mounted) { + _showSnackBar(context, "Task created successfully."); + Navigator.pop(context); + } + } catch (e) { + debugPrint("Data Persistence Error: $e"); + if (context.mounted) { + _showSnackBar(context, "Database synchronization failed."); + } + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void _showSnackBar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } +} + +class CreateTaskScreen extends StatelessWidget { + const CreateTaskScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + bottom: false, + child: Column( + children: [ + _buildHeader(context), + Expanded( + child: Container( + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(40)), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(30), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle("Task Name"), + _buildInputField( + hint: "Enter task name...", + onChanged: (v) => context.read().setTaskName(v), + ), + const SizedBox(height: 25), + _buildSectionTitle("Select Category"), + _buildCategorySelector(), + const SizedBox(height: 25), + _buildDateSelector(context), + const SizedBox(height: 25), + _buildTimeSelectors(context), + const SizedBox(height: 25), + _buildSectionTitle("Description"), + _buildInputField( + hint: "Add supplementary notes...", + maxLines: 4, + onChanged: (v) => context.read().setDescription(v), + ), + const SizedBox(height: 40), + _buildCreateButton(context), + const SizedBox(height: 30), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 15), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), + onPressed: () => Navigator.pop(context), + ), + const Expanded( + child: Center( + child: Text( + "New Task", + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.black), + ), + ), + ), + const SizedBox(width: 48), + ], + ), + ); + } + + Widget _buildSectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text( + title, + style: const TextStyle(fontSize: 16, color: AppColors.primaryBlue, fontWeight: FontWeight.bold) + ), + ); + } + + Widget _buildInputField({required String hint, int maxLines = 1, Function(String)? onChanged}) { + return TextField( + maxLines: maxLines, + onChanged: onChanged, + style: const TextStyle(fontSize: 18, color: Colors.black), + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle(color: AppColors.textDark.withOpacity(0.4), fontSize: 18), + enabledBorder: const UnderlineInputBorder(borderSide: BorderSide(color: Colors.black12)), + focusedBorder: const UnderlineInputBorder(borderSide: BorderSide(color: AppColors.primaryBlue)), + ), + ); + } + + Widget _buildCategorySelector() { + return Consumer( + builder: (context, provider, child) { + final categories = ["Development", "Research", "Design", "Backend"]; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: categories.map((cat) { + final isSelected = cat == provider.selectedCategory; + return GestureDetector( + onTap: () => provider.setCategory(cat), + child: Container( + margin: const EdgeInsets.only(right: 15), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20), + decoration: BoxDecoration( + color: isSelected ? AppColors.primaryBlue : AppColors.inputBackground, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + cat, + style: TextStyle( + color: isSelected ? Colors.white : AppColors.textLightBlue, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + }).toList(), + ), + ); + }, + ); + } + + Widget _buildDateSelector(BuildContext context) { + return Consumer( + builder: (context, provider, child) { + final formattedDate = DateFormat('EEEE, d MMMM').format(provider.selectedDate); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle("Date"), + Row( + children: [ + Expanded( + child: Text(formattedDate, style: const TextStyle(color: Colors.black, fontSize: 18)), + ), + GestureDetector( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: provider.selectedDate, + firstDate: DateTime.now(), + lastDate: DateTime(2100), + ); + if (picked != null) provider.setDate(picked); + }, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: AppColors.primaryBlue, borderRadius: BorderRadius.circular(12)), + child: const Icon(Icons.calendar_month, color: Colors.white, size: 20), + ), + ) + ], + ), + const Divider(color: Colors.black12), + ], + ); + }, + ); + } + + Widget _buildTimeSelectors(BuildContext context) { + return Row( + children: [ + Expanded(child: _timeField(context, "Start Time", true)), + const SizedBox(width: 30), + Expanded(child: _timeField(context, "End Time", false)), + ], + ); + } + + Widget _timeField(BuildContext context, String title, bool isStart) { + return Consumer( + builder: (context, provider, child) { + final time = isStart ? provider.startTime : provider.endTime; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle(title), + GestureDetector( + onTap: () async { + final picked = await showTimePicker(context: context, initialTime: time); + if (picked != null) { + isStart ? provider.setStartTime(picked) : provider.setEndTime(picked); + } + }, + child: Row( + children: [ + Text(time.format(context), style: const TextStyle(color: Colors.black, fontSize: 18)), + const Icon(Icons.keyboard_arrow_down, color: AppColors.primaryBlue, size: 18), + ], + ), + ), + const Divider(color: Colors.black12), + ], + ); + }, + ); + } + + Widget _buildCreateButton(BuildContext context) { + final provider = context.watch(); + return Center( + child: SizedBox( + width: double.infinity, + height: 55, + child: ElevatedButton( + onPressed: provider.isLoading ? null : () => provider.onCreateTaskPressed(context), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), + ), + child: provider.isLoading + ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : const Text("Confirm Task Creation", style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), + ), + ), + ); + } +} \ No newline at end of file diff --git a/src/lib/features/tasks/model/task_model.dart b/src/lib/features/tasks/model/task_model.dart index 3fa2db1..dc5acd3 100644 --- a/src/lib/features/tasks/model/task_model.dart +++ b/src/lib/features/tasks/model/task_model.dart @@ -71,3 +71,23 @@ class TaskModel { this.tags = const [], }); } + +class NoteModel { + final int id; + final String content; + final bool pinned; + + NoteModel({ + required this.id, + required this.content, + this.pinned = false, + }); + + factory NoteModel.fromJson(Map json) { + return NoteModel( + id: json['id'], + content: json['content'] ?? '', + pinned: json['pinned'] ?? false, + ); + } +} 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 7e6fc3e..d6fc754 100644 --- a/src/lib/features/tasks/view/screens/create_task_screen.dart +++ b/src/lib/features/tasks/view/screens/create_task_screen.dart @@ -1,6 +1,15 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +// --- Adjust these import paths to match your project structure --- +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/priority_selector.dart'; // Import 2 cục UI ở trên vào +//import '../widgets/tag_selector.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'; @@ -12,6 +21,141 @@ import '../../viewmodel/task_viewmodel.dart'; import '../widgets/task_widgets.dart'; import '../widgets/priority_selector.dart'; +// ============================================================================ +// 1. STATE MANAGEMENT (PROVIDER) - Xử lý logic Supabase +// ============================================================================ + +class CreateTaskProvider extends ChangeNotifier { + final _supabase = Supabase.instance.client; + + // --- UI State Variables --- + String _selectedCategory = "Development"; + DateTime _selectedDate = DateTime.now(); + TimeOfDay _startTime = const TimeOfDay(hour: 10, minute: 0); + TimeOfDay _endTime = const TimeOfDay(hour: 11, minute: 0); + bool _isLoading = false; + + // --- Getters --- + String get selectedCategory => _selectedCategory; + DateTime get selectedDate => _selectedDate; + TimeOfDay get startTime => _startTime; + TimeOfDay get endTime => _endTime; + bool get isLoading => _isLoading; + + // --- Setters (Triggers UI Rebuild) --- + void setCategory(String value) { + _selectedCategory = value; + notifyListeners(); + } + + void setDate(DateTime value) { + _selectedDate = value; + notifyListeners(); + } + + void setStartTime(TimeOfDay value) { + _startTime = value; + notifyListeners(); + } + + void setEndTime(TimeOfDay value) { + _endTime = value; + notifyListeners(); + } + + Future submitTask( + BuildContext context, { + required String taskName, + required String description, + required dynamic priority, + required List tags, + required int? categoryId, // Thêm tham số ID từ UI truyền vào +}) async { + if (taskName.trim().isEmpty) { + _showSnackBar(context, "Task name is required."); + return; + } + + // Check xem có chọn Category chưa + if (categoryId == null) { + _showSnackBar(context, "Please select a category."); + return; + } + + final user = _supabase.auth.currentUser; + if (user == null) { + _showSnackBar(context, "Session not found. Please re-authenticate."); + return; + } + + _isLoading = true; + notifyListeners(); + + try { + // 1. Xử lý Priority ID + int priorityId = 3; // Mặc định Medium + final String priorityStr = priority.toString().toLowerCase(); + + if (priorityStr.contains('urgent')) { + priorityId = 1; + } else if (priorityStr.contains('high')) { + priorityId = 2; + } else if (priorityStr.contains('medium')) { + priorityId = 3; + } else if (priorityStr.contains('low')) { + priorityId = 4; + } + + // 2. Xử lý thời gian + final scheduledDateTime = DateTime( + _selectedDate.year, + _selectedDate.month, + _selectedDate.day, + _startTime.hour, + _startTime.minute, + ); + + // 3. Insert vào Supabase + await _supabase.from('task').insert({ + 'title': taskName.trim(), + //'description': description.trim(), // Tui mở comment cái này ra cho ông luôn + 'status': 0, + 'priority': priorityId, + 'profile_id': user.id, + 'category_id': categoryId, // Dùng ID thực tế từ UI + 'create_at': scheduledDateTime.toIso8601String(), + }); + + if (context.mounted) { + _showSnackBar(context, "Task created successfully."); + // Chỉ để pop ở đây, bên ngoài UI ông xoá cái pop kia đi nhé + Navigator.pop(context); + } + } catch (e) { + debugPrint("Data Persistence Error: $e"); + if (context.mounted) { + _showSnackBar(context, "Database synchronization failed: $e"); + } + } finally { + _isLoading = false; + notifyListeners(); + } +} + + void _showSnackBar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + behavior: SnackBarBehavior.floating, + ), + ); + } +} + +// ============================================================================ +// 2. USER INTERFACE (UI) - Màn hình tạo Task +// ============================================================================ + class CreateTaskScreen extends StatefulWidget { const CreateTaskScreen({super.key}); @@ -26,9 +170,8 @@ class _CreateTaskScreenState extends State { 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); + + int? _selectedCategoryId; @override @@ -43,9 +186,11 @@ class _CreateTaskScreenState extends State { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; final categoryViewModel = context.watch(); final tagViewModel = context.watch(); - String formattedDate = DateFormat('EEEE, d MMMM').format(_selectedDate); + String formattedDate = DateFormat('EEEE, d MMMM').format(context.read().selectedDate); final categories = categoryViewModel.categories; if (_selectedCategoryId == null && categories.isNotEmpty) { @@ -53,14 +198,14 @@ class _CreateTaskScreenState extends State { } return Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, + backgroundColor: theme.scaffoldBackgroundColor, body: SafeArea( child: Column( children: [ - // ─── Header ─────────────────────────────────────── + // --- Custom Header --- Container( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, + color: theme.colorScheme.surface, borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(30), bottomRight: Radius.circular(30), @@ -141,110 +286,208 @@ class _CreateTaskScreenState extends State { ), const SizedBox(height: 20), - // ─── PRIORITY SELECTOR (MỚI) ────────────── + // --- Gọi 2 Widget ông đã code --- 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, - ), - 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); - } - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - formattedDate, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, + // --- Date --- + Consumer( + builder: (context, provider, child) { + final formattedDate = DateFormat('EEEE, d MMMM') + .format(provider.selectedDate); + + return InkWell( + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: provider.selectedDate, + firstDate: DateTime.now(), + lastDate: DateTime(2100), + ); + if (picked != null) { + provider.setDate(picked); + } + }, + borderRadius: BorderRadius.circular(15), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 5.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Date', style: theme.textTheme.labelLarge), + const SizedBox(height: 5), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + formattedDate, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 5), + Container( + width: 150, + height: 1, + color: theme.colorScheme.outline, + ) + ], ), + ], + ), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(15), + ), + child: const Icon( + Icons.date_range_rounded, + color: Colors.white, ), - const SizedBox(height: 5), - Container( - width: 150, - height: 1, - color: Theme.of(context).colorScheme.outline, - ) - ], - ), + ) + ], ), - ], - ), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(15), ), - child: const Icon(Icons.date_range_rounded, color: Colors.white), - ) - ], + ); + }, ), const SizedBox(height: 25), - // Time - Row( + //Clock + /*Row( children: [ + // --- Start Time --- Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Start time', - style: Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(height: 5), - TimePickerWidget( - time: _startTime, - onChanged: (t) => - setState(() => _startTime = t), - ), - ], + child: Consumer( + builder: (context, provider, child) { + final formattedStartTime = provider.startTime.format(context); + return InkWell( + onTap: () async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: provider.startTime, + ); + if (picked != null) { + provider.setStartTime(picked); + } + }, + borderRadius: BorderRadius.circular(15), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 5.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Start Time', style: theme.textTheme.labelLarge), + const SizedBox(height: 5), + Text( + formattedStartTime, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 5), + Container(height: 1, color: theme.colorScheme.outline), + ], + ), + ), + const SizedBox(width: 10), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.access_time_rounded, color: Colors.white, size: 20), + ), + ], + ), + ), + ); + }, ), ), - const SizedBox(width: 20), + + const SizedBox(width: 20), // Khoảng cách giữa 2 cục thời gian + + // --- End Time --- Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'End time', - style: Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(height: 5), - TimePickerWidget( - time: _endTime, - onChanged: (t) => setState(() => _endTime = t), - ), - ], + child: Consumer( + builder: (context, provider, child) { + final formattedEndTime = provider.endTime.format(context); + return InkWell( + onTap: () async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: provider.endTime, + ); + if (picked != null) { + provider.setEndTime(picked); + } + }, + borderRadius: BorderRadius.circular(15), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 5.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('End Time', style: theme.textTheme.labelLarge), + const SizedBox(height: 5), + Text( + formattedEndTime, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 5), + Container(height: 1, color: theme.colorScheme.outline), + ], + ), + ), + const SizedBox(width: 10), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.access_time_filled_rounded, color: Colors.white, size: 20), + ), + ], + ), + ), + ); + }, ), ), ], + ),*/ + + // --- Input Desc --- + CustomInputField( + label: 'Description', + hint: 'Enter task description', + controller: _descController, + maxLines: 2, ), const SizedBox(height: 25), @@ -259,57 +502,40 @@ class _CreateTaskScreenState extends State { // ─── Create Button ──────────────────────── Center( - child: ElevatedButton( - onPressed: () { - final viewModel = context.read(); - if (categories.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Please create a category first.'), - ), + child: ElevatedButton( + onPressed: () async { + // Kiểm tra loading để tránh user bấm liên tọi + if (context.read().isLoading) return; + + await context.read().submitTask( + context, + taskName: _nameController.text, + description: _descController.text, + priority: context.read().selectedPriority, + tags: List.from(context.read().selectedTags), + categoryId: _selectedCategoryId, // Truyền cái biến state ở UI vào đây ); - 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: selectedCategory, - startTime: _startTime, - endTime: _endTime, - date: _selectedDate, - priority: viewModel.selectedPriority, - tags: List.from(tagViewModel.selectedTags), - ); - viewModel.addTask(newTask); - viewModel.reset(); - context.read().resetSelection(); - 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), + + // Sau khi tạo xong, reset mấy cái linh tinh + if (mounted) { + context.read().reset(); + context.read().resetSelection(); + } + + // KHÔNG dùng Navigator.pop(context) ở đây nữa vì trong Provider làm rồi. + }, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 100, + vertical: 15, + ), ), - ), - child: const Text( - 'Create Task', - style: TextStyle(fontSize: 18), + child: const Text('Create Task', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), ), - ), + const SizedBox(height: 20), ], ), ), 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 54b628e..c124afe 100644 --- a/src/lib/features/tasks/view/screens/task_detail_screen.dart +++ b/src/lib/features/tasks/view/screens/task_detail_screen.dart @@ -64,24 +64,122 @@ class _TaskDetailScreenState extends State { } bool _isTagSelected(TagModel tag) => _currentTags.any((t) => t.id == tag.id); + // Hàm hiển thị Popup để gõ Note mới (để bên trong class TaskDetailScreen) + void _showAddNoteDialog(BuildContext context, String taskId) { + final TextEditingController noteController = TextEditingController(); - void _saveChanges() { - widget.task.title = _titleController.text; - widget.task.description = _descController.text; - widget.task.startTime = _startTime; - widget.task.endTime = _endTime; - widget.task.category = _currentCategory; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Thêm Note'), + content: TextField( + controller: noteController, + maxLines: 3, + decoration: InputDecoration( + hintText: 'Nhập nội dung ghi chú...', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Hủy'), + ), + ElevatedButton( + onPressed: () async { + if (noteController.text.isNotEmpty) { + // Gọi ViewModel để lưu Note + bool success = await context.read().createNote(taskId, noteController.text); + + if (success && context.mounted) { + Navigator.pop(context); + // Load lại trang hoặc gọi setState để thấy note mới (tuỳ cách ông build màn hình) + setState(() {}); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Đã thêm Note!')), + ); + } + } + }, + child: const Text('Lưu'), + ), + ], + ), + ); + } - // Lưu tags mới vào task qua ViewModel - context.read().updateTaskTags(widget.task.id, _currentTags); + // Widget hiển thị Danh Sách Note (Ông nhét cái này vào đâu đó trong body của TaskDetailScreen) + Widget _buildNotesSection(BuildContext context, String taskId) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Notes', style: Theme.of(context).textTheme.titleLarge), + // Nút Dấu Cộng thêm Note + IconButton( + icon: const Icon(Icons.add_circle, color: Colors.blue, size: 30), + onPressed: () => _showAddNoteDialog(context, taskId), + ), + ], + ), + const SizedBox(height: 10), + + // Dùng FutureBuilder để gọi API lấy note về + FutureBuilder>( + future: context.read().getNotesForTask(taskId), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Text('Chưa có ghi chú nào.', style: TextStyle(color: Colors.grey)); + } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Task updated successfully!'), - backgroundColor: Theme.of(context).colorScheme.tertiary, - ), + final notes = snapshot.data!; + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), // Để ListView không cuộn lồng nhau + itemCount: notes.length, + itemBuilder: (context, index) { + return Card( + margin: const EdgeInsets.only(bottom: 10), + child: ListTile( + leading: const Icon(Icons.sticky_note_2, color: Colors.amber), + title: Text(notes[index].content), + ), + ); + }, + ); + }, + ), + ], ); - Navigator.pop(context); + } + void _saveChanges() async { + final Map updates = { + 'title': _titleController.text.trim(), + 'category_id': _currentCategory.id, + }; + + try { + await context.read().updateTask(widget.task.id, updates); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Cập nhật thành công!')), + ); + Navigator.pop(context); // Xong thì té về màn Home + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Lỗi rồi: $e')), + ); + } + } } @override @@ -118,6 +216,40 @@ class _TaskDetailScreenState extends State { ), ), centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.delete_outline_rounded, color: Colors.redAccent), + onPressed: () { + // Hiện Dialog xác nhận xóa cho an toàn + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Xóa Task?'), + content: const Text('Bạn có chắc muốn xóa công việc này không?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Hủy'), + ), + TextButton( + onPressed: () { + // Gọi hàm xóa từ Provider/ViewModel + context.read().deleteTask(widget.task.id); + Navigator.pop(ctx); // Tắt Dialog + Navigator.pop(context); // Trở về màn Home + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Đã xóa task thành công!'), backgroundColor: Colors.redAccent), + ); + }, + child: const Text('Xóa', style: TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold)), + ), + ], + ), + ); + }, + ), + const SizedBox(width: 10), + ], ), body: SafeArea( child: Hero( @@ -294,7 +426,9 @@ class _TaskDetailScreenState extends State { maxLines: 3, ), const SizedBox(height: 40), + _buildNotesSection(context, widget.task.id.toString()), + const SizedBox(height: 40), // Save Button Center( child: ElevatedButton( diff --git a/src/lib/features/tasks/view/widgets/task_widgets.dart b/src/lib/features/tasks/view/widgets/task_widgets.dart index 426d182..1e9a684 100644 --- a/src/lib/features/tasks/view/widgets/task_widgets.dart +++ b/src/lib/features/tasks/view/widgets/task_widgets.dart @@ -82,11 +82,13 @@ class DateBox extends StatelessWidget { class TaskCard extends StatelessWidget { final TaskModel task; final Widget leading; + final Widget? trailing; // 1. Thêm biến trailing để nhận nhãn Priority const TaskCard({ super.key, required this.task, required this.leading, + this.trailing, // 2. Bổ sung vào constructor }); @override @@ -138,6 +140,7 @@ class TaskCard extends StatelessWidget { children: [ leading, const SizedBox(width: 15), + // Phần tiêu đề và mô tả Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -148,13 +151,26 @@ class TaskCard extends StatelessWidget { ], ), ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondary, - borderRadius: BorderRadius.circular(10), - ), - child: Text(timeString, style: const TextStyle(color: Colors.white, fontSize: 12)), + const SizedBox(width: 10), + + // 3. CỤM HIỂN THỊ TRÊN GÓC PHẢI (Priority đè lên Time) + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (trailing != null) ...[ + trailing!, // Hiển thị nhãn Priority ở đây + const SizedBox(height: 8), // Cách cái giờ ra một tí + ], + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + borderRadius: BorderRadius.circular(10), + ), + child: Text(timeString, style: const TextStyle(color: Colors.white, fontSize: 12)), + ), + ], ), ], ), @@ -167,7 +183,6 @@ class TaskCard extends StatelessWidget { ); } } - // --- Widget chọn giờ (TimePickerWidget) --- class TimePickerWidget extends StatelessWidget { final TimeOfDay time; diff --git a/src/lib/features/tasks/viewmodel/task_viewmodel.dart b/src/lib/features/tasks/viewmodel/task_viewmodel.dart index c943500..4a4dedf 100644 --- a/src/lib/features/tasks/viewmodel/task_viewmodel.dart +++ b/src/lib/features/tasks/viewmodel/task_viewmodel.dart @@ -1,9 +1,85 @@ import 'package:flutter/material.dart'; import 'package:task_management_app/features/tag/model/tag_model.dart'; - +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; import '../model/task_model.dart'; +import 'package:task_management_app/features/category/model/category_model.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; class TaskViewModel extends ChangeNotifier { + + DateTime _selectedDate = DateTime.now(); + DateTime get selectedDate => _selectedDate; + + void setDate(DateTime date) { + _selectedDate = date; + notifyListeners(); + } + + // ─── 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.fromJson(e)).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: DateTime.now().millisecondsSinceEpoch, + name: name, + colorCode: '#FF9800', + profileId: '', + ), + ); + _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; @@ -46,32 +122,126 @@ class TaskViewModel extends ChangeNotifier { notifyListeners(); } + // Cập nhật tag cho task đã tạo (dùng ở Task Detail) + Future updateTaskTags(String taskId, List newTags) async { + final index = _tasks.indexWhere((t) => t.id == taskId); + if (index != -1) { + _tasks[index].tags = newTags; + notifyListeners(); + } + } void addTask(TaskModel task) { _tasks.add(task); notifyListeners(); } // Cập nhật tag cho task đã tạo (dùng ở Task Detail) - void updateTaskTags(String taskId, List newTags) { - final index = _tasks.indexWhere((t) => t.id == taskId); - if (index != -1) { - _tasks[index].tags = newTags; + + Future fetchTasks() async { + final supabase = Supabase.instance.client; + final user = supabase.auth.currentUser; + + if (user == null) return; + + try { + final data = await supabase + .from('task') + .select('*') + .eq('profile_id', user.id) + .order('create_at', ascending: true); + + if (data != null) { + _tasks.clear(); + + for (var item in data) { + // 1. Chuyển đổi Priority trực tiếp + Priority p = Priority.medium; + if (item['priority'] == 1) p = Priority.urgent; + else if (item['priority'] == 2) p = Priority.high; + else if (item['priority'] == 4) p = Priority.low; + + // 2. Nhét data thẳng vào TaskModel luôn, đách cần fromJson nữa + _tasks.add(TaskModel( + id: item['id'].toString(), + title: item['title'] ?? 'Task mới', + description: item['description'] ?? '', + + // CHÍNH LÀ CHỖ NÀY: Khởi tạo cục CategoryModel đàng hoàng + category: CategoryModel( + id: 0, // Nhét số 0 vào làm ID ảo + name: item['category']?.toString() ?? 'General', // Lấy tên từ database, nếu rỗng thì cho chữ General + colorCode: '#5A8DF3', // Lấy màu mặc định + profileId: '', // Bỏ trống + ), + + // Mấy cái giờ giấc cho mặc định hết đi, chừng nào khỏe code tiếp + startTime: const TimeOfDay(hour: 8, minute: 0), + endTime: const TimeOfDay(hour: 9, minute: 0), + date: item['create_at'] != null + ? DateTime.tryParse(item['create_at'].toString()) ?? DateTime.now() + : DateTime.now(), + priority: p, + )); + } + } notifyListeners(); + + } catch (e) { + debugPrint("Lỗi lấy task: $e"); + } + } + + + Future updateTask(dynamic taskId, Map data) async { + final _supabase = Supabase.instance.client; + try { + await _supabase + .from('task') + .update(data) // Data ở đây sẽ chứa {'title': '...', 'category_id': ...} + .eq('id', taskId); + + notifyListeners(); // Để màn hình Home load lại dữ liệu mới + } catch (e) { + rethrow; + } +} + + Future deleteTask(String taskId) async { + final supabase = Supabase.instance.client; + try { + await supabase.from('task').delete().eq('id', taskId); + // Gọi fetch lại để làm mới danh sách + fetchTasks(); + } catch (e) { + debugPrint("Lỗi xóa: $e"); } } List _getFilteredAndSorted() { List result = List.from(_tasks); + + // 1. Lọc theo ngày (Date) + // Tìm đoạn này trong _getFilteredAndSorted() + result = result.where((t) { + // Đổi t.startTime thành t.date (hoặc tên biến đúng của ông) + return t.date.day == _selectedDate.day && + t.date.month == _selectedDate.month && + t.date.year == _selectedDate.year; + }).toList(); + + // 2. Lọc theo Priority (Logic so sánh rất gọn vì dùng thẳng enum) if (_filterPriority != null) { result = result.where((t) => t.priority == _filterPriority).toList(); } + + // 3. Lọc theo Tag if (_filterTagId != null) { result = result .where((t) => t.tags.any((tag) => tag.id.toString() == _filterTagId)) .toList(); } - // Sắp xếp theo priority (urgent → high → medium → low) + // 4. Sắp xếp theo priority (urgent → high → medium → low) if (_sortByPriority) { const order = [ Priority.urgent, @@ -84,6 +254,39 @@ class TaskViewModel extends ChangeNotifier { order.indexOf(a.priority).compareTo(order.indexOf(b.priority)), ); } + return result; } + + Future> getNotesForTask(String taskId) async { + final supabase = Supabase.instance.client; + try { + final data = await supabase + .from('note') + .select('*') + .eq('task_id', int.parse(taskId)) // Tìm note theo ID của task + .order('id', ascending: false); // Sắp xếp note mới nhất lên đầu + + return (data as List).map((e) => NoteModel.fromJson(e)).toList(); + } catch (e) { + debugPrint("Lỗi lấy note: $e"); + return []; + } + } + + // 2. Thêm note mới vào DB + Future createNote(String taskId, String content) async { + final supabase = Supabase.instance.client; + try { + await supabase.from('note').insert({ + 'task_id': int.parse(taskId), // Nối đúng vào task hiện tại + 'content': content, + 'pinned': false, // Mặc định không ghim + }); + return true; // Báo hiệu lưu thành công + } catch (e) { + debugPrint("Lỗi tạo note: $e"); + return false; + } + } } diff --git a/src/lib/main.dart b/src/lib/main.dart index 3be01b7..09178fe 100644 --- a/src/lib/main.dart +++ b/src/lib/main.dart @@ -10,6 +10,9 @@ import 'package:task_management_app/features/tasks/viewmodel/task_viewmodel.dart import 'core/theme/app_theme.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:task_management_app/features/tasks/view/screens/create_task_screen.dart'; + +import 'core/theme/theme_provider.dart'; import 'core/theme/theme_provider.dart'; @@ -31,6 +34,22 @@ Future main() async { SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle(statusBarColor: Colors.transparent)); + + ErrorWidget.builder = (FlutterErrorDetails details) { + return Material( + child: Container( + color: Colors.black87, + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + child: Text( + 'Lỗi!\n\n${details.exception}\n\nStack Trace:\n${details.stack}', + style: const TextStyle(color: Colors.greenAccent, fontSize: 14), + ), + ), + ), + ); + }; + runApp( MultiProvider( providers: [ diff --git a/src/pubspec.lock b/src/pubspec.lock index 72a5d4a..33dc948 100644 --- a/src/pubspec.lock +++ b/src/pubspec.lock @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -436,18 +436,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -745,10 +745,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" typed_data: dependency: transitive description: diff --git a/src/pubspec.yaml b/src/pubspec.yaml index 9afc694..0cda89c 100644 --- a/src/pubspec.yaml +++ b/src/pubspec.yaml @@ -30,6 +30,7 @@ environment: dependencies: flutter: sdk: flutter + # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. @@ -41,7 +42,7 @@ dependencies: provider: ^6.1.5+1 flutter_ringtone_player: ^4.0.0+4 image_picker: ^1.2.1 - shared_preferences: ^2.2.2 + shared_preferences: ^2.5.5 flutter_heatmap_calendar: ^1.0.5 google_generative_ai: ^0.4.7 diff --git a/src/web/favicon.png b/src/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/src/web/favicon.png differ diff --git a/src/web/icons/Icon-192.png b/src/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/src/web/icons/Icon-192.png differ diff --git a/src/web/icons/Icon-512.png b/src/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/src/web/icons/Icon-512.png differ diff --git a/src/web/icons/Icon-maskable-192.png b/src/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/src/web/icons/Icon-maskable-192.png differ diff --git a/src/web/icons/Icon-maskable-512.png b/src/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/src/web/icons/Icon-maskable-512.png differ diff --git a/src/web/index.html b/src/web/index.html new file mode 100644 index 0000000..e408f1b --- /dev/null +++ b/src/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + task_management_app + + + + + + + diff --git a/src/web/manifest.json b/src/web/manifest.json new file mode 100644 index 0000000..57a4a37 --- /dev/null +++ b/src/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "task_management_app", + "short_name": "task_management_app", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}