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.
This commit is contained in:
Vaibhav Surve 2025-05-12 11:13:22 +05:30
parent 809c048de6
commit db0b525e87
7 changed files with 673 additions and 17 deletions

View File

@ -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<ProjectModel> projects = [];
String? selectedProjectId;
DateTime? startDateTask;
DateTime? endDateTask;
List<TaskModel> 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<void> 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<void> 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<String, List<TaskModel>> 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<void> 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);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,153 @@
class TaskModel {
final String assignmentDate;
final WorkItem? workItem;
final int plannedTask;
final int completedTask;
final AssignedBy assignedBy;
final List<TeamMember> teamMembers;
final List<Comment> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
return ActivityMaster(activityName: json['activityName']);
}
}
class WorkArea {
final String areaName;
final Floor? floor;
WorkArea({required this.areaName, this.floor});
factory WorkArea.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
return Building(name: json['name']);
}
}
class AssignedBy {
final String firstName;
final String? lastName;
AssignedBy({required this.firstName, this.lastName});
factory AssignedBy.fromJson(Map<String, dynamic> json) {
return AssignedBy(
firstName: json['firstName'],
lastName: json['lastName'],
);
}
}
class TeamMember {
final String firstName;
TeamMember({required this.firstName});
factory TeamMember.fromJson(Map<String, dynamic> json) {
return TeamMember(firstName: json['firstName']);
}
}
class Comment {
final String comment;
Comment({required this.comment});
factory Comment.fromJson(Map<String, dynamic> json) {
return Comment(comment: json['comment']);
}
}

View File

@ -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(

View File

@ -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<DailyTaskScreen> createState() => _DailyTaskScreenState();
}
class _DailyTaskScreenState extends State<DailyTaskScreen> 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<DailyTaskController>(
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<String>(
onSelected: (String value) async {
if (value.isNotEmpty) {
dailyTaskController.selectedProjectId = value;
await dailyTaskController.fetchTaskData(value);
}
dailyTaskController.update();
},
itemBuilder: (BuildContext context) {
return dailyTaskController.projects
.map<PopupMenuItem<String>>((project) {
return PopupMenuItem<String>(
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<String, List<dynamic>> 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<DataRow> 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<DataColumn> _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<Widget>((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),
),
),
),
],
),
),
);
}
}

View File

@ -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<Widget> _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);
}

View File

@ -124,6 +124,11 @@ class _LeftBarState extends State<LeftBar>
title: "Employees",
isCondensed: isCondensed,
route: '/dashboard/employees'),
NavigationItem(
iconData: LucideIcons.list,
title: "Daily Task",
isCondensed: isCondensed,
route: '/dashboard/daily-task'),
],
),
),