From db0b525e87edfb0c4d783b302dcd18753f16d691 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 12 May 2025 11:13:22 +0530 Subject: [PATCH] Add Daily Task feature with controller, model, and UI integration - Implement DailyTaskController for managing daily tasks and fetching projects. - Create TaskModel to represent task data structure. - Develop DailyTaskScreen for displaying tasks with filtering options. - Update routes to include Daily Task navigation. - Enhance DashboardScreen to link to Daily Task. - Add Daily Task option in the left navigation bar. --- .../dashboard/daily_task_controller.dart | 118 ++++++ lib/helpers/widgets/avatar.dart | 28 +- lib/model/daily_task_model.dart | 153 ++++++++ lib/routes.dart | 6 + lib/view/dashboard/daily_task_screen.dart | 356 ++++++++++++++++++ lib/view/dashboard/dashboard_screen.dart | 24 +- lib/view/layouts/left_bar.dart | 5 + 7 files changed, 673 insertions(+), 17 deletions(-) create mode 100644 lib/controller/dashboard/daily_task_controller.dart create mode 100644 lib/model/daily_task_model.dart create mode 100644 lib/view/dashboard/daily_task_screen.dart diff --git a/lib/controller/dashboard/daily_task_controller.dart b/lib/controller/dashboard/daily_task_controller.dart new file mode 100644 index 0000000..5ea99fc --- /dev/null +++ b/lib/controller/dashboard/daily_task_controller.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:logger/logger.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/model/project_model.dart'; +import 'package:marco/model/daily_task_model.dart'; + +final Logger log = Logger(); + +class DailyTaskController extends GetxController { + List projects = []; + String? selectedProjectId; + + DateTime? startDateTask; + DateTime? endDateTask; + + List dailyTasks = []; + + RxBool isLoading = false.obs; + + @override + void onInit() { + super.onInit(); + _initializeDefaults(); + } + + void _initializeDefaults() { + _setDefaultDateRange(); + fetchProjects(); + } + + void _setDefaultDateRange() { + final today = DateTime.now(); + startDateTask = today.subtract(const Duration(days: 7)); + endDateTask = today; + log.i("Default date range set: $startDateTask to $endDateTask"); + } + + Future fetchProjects() async { + isLoading.value = true; + + final response = await ApiService.getProjects(); + isLoading.value = false; + + if (response?.isEmpty ?? true) { + log.w("No project data found or API call failed."); + return; + } + + projects = response!.map((json) => ProjectModel.fromJson(json)).toList(); + selectedProjectId = projects.first.id.toString(); + log.i("Projects fetched: ${projects.length} projects loaded."); + + await fetchTaskData(selectedProjectId); + } + +Future fetchTaskData(String? projectId) async { + if (projectId == null) return; + + isLoading.value = true; + final response = await ApiService.getDailyTasks( + projectId, + dateFrom: startDateTask, + dateTo: endDateTask, + ); + isLoading.value = false; + + if (response != null) { + Map> groupedTasks = {}; + + for (var taskJson in response) { + TaskModel task = TaskModel.fromJson(taskJson); + String assignmentDateKey = task.assignmentDate; + + if (groupedTasks.containsKey(assignmentDateKey)) { + groupedTasks[assignmentDateKey]?.add(task); + } else { + groupedTasks[assignmentDateKey] = [task]; + } + } + dailyTasks = groupedTasks.entries + .map((entry) => entry.value) + .expand((taskList) => taskList) + .toList(); + + log.i("Daily tasks fetched and grouped: ${dailyTasks.length}"); + + update(); + } else { + log.e("Failed to fetch daily tasks for project $projectId"); + } +} + + + Future selectDateRangeForTaskData( + BuildContext context, + DailyTaskController controller, + ) async { + final picked = await showDateRangePicker( + context: context, + firstDate: DateTime(2022), + lastDate: DateTime.now(), + initialDateRange: DateTimeRange( + start: startDateTask ?? DateTime.now().subtract(const Duration(days: 7)), + end: endDateTask ?? DateTime.now(), + ), + ); + + if (picked == null) return; + + startDateTask = picked.start; + endDateTask = picked.end; + + log.i("Date range selected: $startDateTask to $endDateTask"); + + await controller.fetchTaskData(controller.selectedProjectId); + } +} diff --git a/lib/helpers/widgets/avatar.dart b/lib/helpers/widgets/avatar.dart index 2bef33f..16b546c 100644 --- a/lib/helpers/widgets/avatar.dart +++ b/lib/helpers/widgets/avatar.dart @@ -1,40 +1,50 @@ import 'package:flutter/material.dart'; import 'package:marco/helpers/widgets/my_container.dart'; import 'package:marco/helpers/widgets/my_text.dart'; + class Avatar extends StatelessWidget { final String firstName; final String lastName; final double size; - final Color backgroundColor; + final Color? backgroundColor; // Optional: allows override final Color textColor; - - // Constructor + const Avatar({ super.key, required this.firstName, required this.lastName, - this.size = 46.0, // Default size - this.backgroundColor = Colors.blue, // Default background color - this.textColor = Colors.white, // Default text color + this.size = 46.0, + this.backgroundColor, + this.textColor = Colors.white, }); @override Widget build(BuildContext context) { - // Extract first letters of firstName and lastName String initials = "${firstName.isNotEmpty ? firstName[0] : ''}${lastName.isNotEmpty ? lastName[0] : ''}".toUpperCase(); + final Color bgColor = backgroundColor ?? _generateColorFromName('$firstName$lastName'); + return MyContainer.rounded( height: size, width: size, paddingAll: 0, - color: backgroundColor, // Background color of the avatar + color: bgColor, child: Center( child: MyText.labelSmall( initials, fontWeight: 600, - color: textColor, // Text color of the initials + color: textColor, ), ), ); } + + // Generate a consistent "random-like" color from the name + Color _generateColorFromName(String name) { + final hash = name.hashCode; + final r = (hash & 0xFF0000) >> 16; + final g = (hash & 0x00FF00) >> 8; + final b = (hash & 0x0000FF); + return Color.fromARGB(255, r, g, b).withOpacity(1.0); + } } diff --git a/lib/model/daily_task_model.dart b/lib/model/daily_task_model.dart new file mode 100644 index 0000000..6799f0f --- /dev/null +++ b/lib/model/daily_task_model.dart @@ -0,0 +1,153 @@ +class TaskModel { + final String assignmentDate; + final WorkItem? workItem; + final int plannedTask; + final int completedTask; + final AssignedBy assignedBy; + final List teamMembers; + final List comments; + + // Remove plannedWork and completedWork from direct properties + int get plannedWork => workItem?.plannedWork ?? 0; + int get completedWork => workItem?.completedWork ?? 0; + + TaskModel({ + required this.assignmentDate, + required this.workItem, + required this.plannedTask, + required this.completedTask, + required this.assignedBy, + required this.teamMembers, + required this.comments, + }); + + factory TaskModel.fromJson(Map json) { + final workItemJson = json['workItem']; + final workItem = workItemJson != null ? WorkItem.fromJson(workItemJson) : null; + + return TaskModel( + assignmentDate: json['assignmentDate'], + workItem: workItem, + plannedTask: json['plannedTask'], + completedTask: json['completedTask'], + assignedBy: AssignedBy.fromJson(json['assignedBy']), + teamMembers: (json['teamMembers'] as List) + .map((e) => TeamMember.fromJson(e)) + .toList(), + comments: (json['comments'] as List) + .map((e) => Comment.fromJson(e)) + .toList(), + ); + } +} + +class WorkItem { + final ActivityMaster? activityMaster; + final WorkArea? workArea; + + // Add plannedWork and completedWork as properties of WorkItem + final int? plannedWork; + final int? completedWork; + + WorkItem({ + this.activityMaster, + this.workArea, + this.plannedWork, + this.completedWork, + }); + + factory WorkItem.fromJson(Map json) { + return WorkItem( + activityMaster: json['activityMaster'] != null + ? ActivityMaster.fromJson(json['activityMaster']) + : null, + workArea: json['workArea'] != null ? WorkArea.fromJson(json['workArea']) : null, + plannedWork: json['plannedWork'], + completedWork: json['completedWork'], + ); + } +} + + +class ActivityMaster { + final String activityName; + + ActivityMaster({required this.activityName}); + + factory ActivityMaster.fromJson(Map json) { + return ActivityMaster(activityName: json['activityName']); + } +} + +class WorkArea { + final String areaName; + final Floor? floor; + + WorkArea({required this.areaName, this.floor}); + + factory WorkArea.fromJson(Map json) { + return WorkArea( + areaName: json['areaName'], + floor: json['floor'] != null ? Floor.fromJson(json['floor']) : null, + ); + } +} + +class Floor { + final String floorName; + final Building? building; + + Floor({required this.floorName, this.building}); + + factory Floor.fromJson(Map json) { + return Floor( + floorName: json['floorName'], + building: + json['building'] != null ? Building.fromJson(json['building']) : null, + ); + } +} + +class Building { + final String name; + + Building({required this.name}); + + factory Building.fromJson(Map json) { + return Building(name: json['name']); + } +} + +class AssignedBy { + final String firstName; + final String? lastName; + + AssignedBy({required this.firstName, this.lastName}); + + factory AssignedBy.fromJson(Map json) { + return AssignedBy( + firstName: json['firstName'], + lastName: json['lastName'], + ); + } +} + +class TeamMember { + final String firstName; + + TeamMember({required this.firstName}); + + factory TeamMember.fromJson(Map json) { + return TeamMember(firstName: json['firstName']); + } +} + +class Comment { + final String comment; + + Comment({required this.comment}); + + factory Comment.fromJson(Map json) { + return Comment(comment: json['comment']); + } +} diff --git a/lib/routes.dart b/lib/routes.dart index 77a1abe..c074291 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -14,6 +14,7 @@ import 'package:marco/view/dashboard/attendanceScreen.dart'; import 'package:marco/view/dashboard/dashboard_screen.dart'; import 'package:marco/view/dashboard/add_employee_screen.dart'; import 'package:marco/view/dashboard/employee_screen.dart'; +import 'package:marco/view/dashboard/daily_task_screen.dart'; class AuthMiddleware extends GetMiddleware { @override @@ -47,6 +48,11 @@ getPageRoute() { name: '/employees/addEmployee', page: () => AddEmployeeScreen(), middlewares: [AuthMiddleware()]), + // Daily Task Planning + GetPage( + name: '/dashboard/daily-task', + page: () => DailyTaskScreen(), + middlewares: [AuthMiddleware()]), // Authentication GetPage(name: '/auth/login', page: () => LoginScreen()), GetPage( diff --git a/lib/view/dashboard/daily_task_screen.dart b/lib/view/dashboard/daily_task_screen.dart new file mode 100644 index 0000000..8d758e7 --- /dev/null +++ b/lib/view/dashboard/daily_task_screen.dart @@ -0,0 +1,356 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/theme/app_theme.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/my_breadcrumb.dart'; +import 'package:marco/helpers/widgets/my_breadcrumb_item.dart'; +import 'package:marco/helpers/widgets/my_flex.dart'; +import 'package:marco/helpers/widgets/my_flex_item.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/view/layouts/layout.dart'; +import 'package:marco/controller/permission_controller.dart'; +import 'package:marco/helpers/widgets/my_loading_component.dart'; +import 'package:marco/helpers/widgets/my_refresh_wrapper.dart'; +import 'package:marco/model/my_paginated_table.dart'; +import 'package:marco/controller/dashboard/daily_task_controller.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:intl/intl.dart'; + +class DailyTaskScreen extends StatefulWidget { + const DailyTaskScreen({super.key}); + + @override + State createState() => _DailyTaskScreenState(); +} + +class _DailyTaskScreenState extends State with UIMixin { + final DailyTaskController dailyTaskController = + Get.put(DailyTaskController()); + final PermissionController permissionController = + Get.put(PermissionController()); + + @override + Widget build(BuildContext context) { + return Layout( + child: Obx(() { + return LoadingComponent( + isLoading: dailyTaskController.isLoading.value, + loadingText: 'Loading Tasks...', + child: GetBuilder( + init: dailyTaskController, + builder: (controller) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + MySpacing.height(flexSpacing), + _buildBreadcrumb(), + MySpacing.height(flexSpacing), + _buildFilterSection(), + MySpacing.height(flexSpacing), + _buildTaskList(), + ], + ); + }, + ), + ); + }), + ); + } + + Widget _buildHeader() { + return Padding( + padding: MySpacing.x(flexSpacing), + child: MyText.titleMedium( + "Daily Task", + fontSize: 18, + fontWeight: 600, + ), + ); + } + + Widget _buildBreadcrumb() { + return Padding( + padding: MySpacing.x(flexSpacing), + child: MyBreadcrumb( + children: [ + MyBreadcrumbItem(name: 'Dashboard'), + MyBreadcrumbItem(name: 'Daily Task', active: true), + ], + ), + ); + } + + Widget _buildFilterSection() { + return Padding( + padding: MySpacing.x(flexSpacing), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildProjectFilter(), + const SizedBox(width: 10), + _buildDateRangeButton(), + ], + ), + ); + } + + Widget _buildProjectFilter() { + return Expanded( + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.black, width: 1.5), + borderRadius: BorderRadius.circular(4), + ), + child: PopupMenuButton( + onSelected: (String value) async { + if (value.isNotEmpty) { + dailyTaskController.selectedProjectId = value; + await dailyTaskController.fetchTaskData(value); + } + dailyTaskController.update(); + }, + itemBuilder: (BuildContext context) { + return dailyTaskController.projects + .map>((project) { + return PopupMenuItem( + value: project.id, + child: MyText.bodySmall(project.name), + ); + }).toList(); + }, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: Text( + dailyTaskController.selectedProjectId == null + ? dailyTaskController.projects.isNotEmpty + ? dailyTaskController.projects.first.name + : 'No Tasks' + : dailyTaskController.projects + .firstWhere((project) => + project.id == dailyTaskController.selectedProjectId) + .name, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.w600), + ), + ), + ), + ), + ); + } + + Widget _buildDateRangeButton() { + return Padding( + padding: const EdgeInsets.all(8.0), + child: TextButton.icon( + icon: const Icon(Icons.date_range), + label: const Text("Select Date Range"), + onPressed: () => dailyTaskController.selectDateRangeForTaskData( + context, dailyTaskController), + ), + ); + } + + Widget _buildTaskList() { + return Padding( + padding: MySpacing.x(flexSpacing / 2), + child: MyFlex( + children: [ + MyFlexItem(sizes: 'lg-6', child: employeeListTab()), + ], + ), + ); + } + + Widget employeeListTab() { + if (dailyTaskController.dailyTasks.isEmpty) { + return Center( + child: MyText.bodySmall("No Tasks Assigned to This Project", + fontWeight: 600), + ); + } + Map> groupedTasks = {}; + for (var task in dailyTaskController.dailyTasks) { + String dateKey = + DateFormat('dd-MM-yyyy').format(DateTime.parse(task.assignmentDate)); + groupedTasks.putIfAbsent(dateKey, () => []).add(task); + } + +// Sort dates descending (latest first) + final sortedEntries = groupedTasks.entries.toList() + ..sort((a, b) => DateFormat('dd-MM-yyyy') + .parse(b.key) + .compareTo(DateFormat('dd-MM-yyyy').parse(a.key))); + + // Flatten grouped data into one list with optional visual separators + List allRows = []; + + for (var entry in sortedEntries) { + allRows.add( + DataRow( + color: WidgetStateProperty.all(Colors.grey.shade200), + cells: [ + DataCell(MyText.titleSmall('Date: ${entry.key}')), + DataCell(MyText.titleSmall('')), + DataCell(MyText.titleSmall('')), + DataCell(MyText.titleSmall('')), + DataCell(MyText.titleSmall('')), + DataCell(MyText.titleSmall('')), + ], + ), + ); + + allRows.addAll(entry.value.map((task) => _buildRow(task))); + } + + return MyRefreshableContent( + onRefresh: () async { + if (dailyTaskController.selectedProjectId != null) { + await dailyTaskController + .fetchTaskData(dailyTaskController.selectedProjectId!); + } + }, + child: MyPaginatedTable( + columns: _buildColumns(), + rows: allRows, + ), + ); + } + + List _buildColumns() { + return [ + DataColumn( + label: MyText.labelLarge('Activity', color: contentTheme.primary)), + DataColumn( + label: MyText.labelLarge('Assigned', color: contentTheme.primary)), + DataColumn( + label: MyText.labelLarge('Completed', color: contentTheme.primary)), + DataColumn( + label: MyText.labelLarge('Assigned On', color: contentTheme.primary)), + DataColumn(label: MyText.labelLarge('Team', color: contentTheme.primary)), + DataColumn( + label: MyText.labelLarge('Actions', color: contentTheme.primary)), + ]; + } + + DataRow _buildRow(dynamic task) { + final workItem = task.workItem; + final location = [ + workItem?.workArea?.floor?.building?.name, + workItem?.workArea?.floor?.floorName, + workItem?.workArea?.areaName + ].where((e) => e != null && e.isNotEmpty).join(' > '); + + return DataRow(cells: [ + DataCell(Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MyText.bodyMedium(workItem?.activityMaster?.activityName ?? 'N/A'), + const SizedBox(height: 2), + MyText.bodySmall(location), + ], + )), + DataCell( + MyText.bodyMedium( + '${task.plannedTask ?? "NA"} / ' + '${(workItem?.plannedWork != null && workItem?.completedWork != null) ? (workItem!.plannedWork! - workItem.completedWork!) : "NA"}', + ), + ), + DataCell(MyText.bodyMedium(task.completedTask.toString())), + DataCell(MyText.bodyMedium(DateFormat('dd-MM-yyyy') + .format(DateTime.parse(task.assignmentDate)))), + DataCell(_buildTeamCell(task)), + DataCell(Row( + children: [ + ElevatedButton( + onPressed: () {}, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 6), + minimumSize: const Size(60, 20), + textStyle: const TextStyle(fontSize: 12), + ), + child: const Text("Report"), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () {}, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 6), + minimumSize: const Size(60, 20), + textStyle: const TextStyle(fontSize: 12), + ), + child: const Text("Comment"), + ), + ], + )), + ]); + } + + Widget _buildTeamCell(dynamic task) { + return GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: MyText.bodyMedium("Team Members"), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: task.teamMembers.map((member) { + return ListTile( + leading: Avatar( + firstName: member.firstName, + lastName: '', + size: 32, + ), + title: Text(member.firstName), + ); + }).toList(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: MyText.bodyMedium("Close"), + ), + ], + ), + ); + }, + child: SizedBox( + height: 32, + width: 100, + child: Stack( + children: [ + for (int i = 0; i < task.teamMembers.length.clamp(0, 3); i++) + Positioned( + left: i * 24.0, + child: Tooltip( + message: task.teamMembers[i].firstName, + child: Avatar( + firstName: task.teamMembers[i].firstName, + lastName: '', + size: 32, + ), + ), + ), + if (task.teamMembers.length > 3) + Positioned( + left: 2 * 24.0, + child: CircleAvatar( + radius: 16, + backgroundColor: Colors.grey.shade300, + child: MyText.bodyMedium( + '+${task.teamMembers.length - 3}', + style: const TextStyle(fontSize: 12, color: Colors.black87), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 81302f1..cc66b20 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -15,7 +15,11 @@ import 'package:marco/view/layouts/layout.dart'; class DashboardScreen extends StatelessWidget with UIMixin { DashboardScreen({super.key}); - static const String dashboardRoute = "/dashboard/attendance"; + static const String dashboardRoute = "/dashboard"; + static const String employeesRoute = "/dashboard/employees"; + static const String projectsRoute = "/dashboard"; + static const String attendanceRoute = "/dashboard/attendance"; + static const String tasksRoute = "/dashboard/daily-task"; @override Widget build(BuildContext context) { @@ -47,10 +51,12 @@ class DashboardScreen extends StatelessWidget with UIMixin { List _buildDashboardStats() { final stats = [ - _StatItem(LucideIcons.gauge, "Dashboard", contentTheme.primary), - _StatItem(LucideIcons.folder, "Projects", contentTheme.secondary), - _StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success), - _StatItem(LucideIcons.logs, "Task", contentTheme.info), + _StatItem(LucideIcons.gauge, "Dashboard", contentTheme.primary, dashboardRoute), + _StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success, attendanceRoute), + _StatItem( LucideIcons.users, "Employees", contentTheme.warning, employeesRoute), + _StatItem(LucideIcons.logs, "Daily Task", contentTheme.info, tasksRoute), + _StatItem(LucideIcons.logs, "Daily Task Planing", contentTheme.info, tasksRoute), + _StatItem(LucideIcons.folder, "Projects", contentTheme.secondary, projectsRoute), ]; return List.generate( @@ -59,7 +65,8 @@ class DashboardScreen extends StatelessWidget with UIMixin { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _buildStatCard(stats[index * 2]), - if (index * 2 + 1 < stats.length) _buildStatCard(stats[index * 2 + 1]), + if (index * 2 + 1 < stats.length) + _buildStatCard(stats[index * 2 + 1]), ], ), ); @@ -68,7 +75,7 @@ class DashboardScreen extends StatelessWidget with UIMixin { Widget _buildStatCard(_StatItem statItem) { return Expanded( child: InkWell( - onTap: () => Get.toNamed(dashboardRoute), + onTap: () => Get.toNamed(statItem.route), child: MyCard.bordered( borderRadiusAll: 10, border: Border.all(color: Colors.grey.withOpacity(0.2)), @@ -105,6 +112,7 @@ class _StatItem { final IconData icon; final String title; final Color color; + final String route; // New field to store the route for each stat item - _StatItem(this.icon, this.title, this.color); + _StatItem(this.icon, this.title, this.color, this.route); } diff --git a/lib/view/layouts/left_bar.dart b/lib/view/layouts/left_bar.dart index c2a2daa..9747f75 100644 --- a/lib/view/layouts/left_bar.dart +++ b/lib/view/layouts/left_bar.dart @@ -124,6 +124,11 @@ class _LeftBarState extends State title: "Employees", isCondensed: isCondensed, route: '/dashboard/employees'), + NavigationItem( + iconData: LucideIcons.list, + title: "Daily Task", + isCondensed: isCondensed, + route: '/dashboard/daily-task'), ], ), ),