Feature_Report_Action #48

Merged
vaibhav.surve merged 19 commits from Feature_Report_Action into main 2025-06-23 07:32:30 +00:00
30 changed files with 3970 additions and 1224 deletions

View File

@ -25,7 +25,7 @@ class DailyTaskController extends GetxController {
} }
} }
RxBool isLoading = false.obs; RxBool isLoading = true.obs;
Map<String, List<TaskModel>> groupedDailyTasks = {}; Map<String, List<TaskModel>> groupedDailyTasks = {};
@override @override
void onInit() { void onInit() {
@ -35,7 +35,6 @@ class DailyTaskController extends GetxController {
void _initializeDefaults() { void _initializeDefaults() {
_setDefaultDateRange(); _setDefaultDateRange();
fetchProjects();
} }
void _setDefaultDateRange() { void _setDefaultDateRange() {
@ -45,22 +44,6 @@ class DailyTaskController extends GetxController {
log.i("Default date range set: $startDateTask to $endDateTask"); 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();
log.i("Projects fetched: ${projects.length} projects loaded.");
update();
}
Future<void> fetchTaskData(String? projectId) async { Future<void> fetchTaskData(String? projectId) async {
if (projectId == null) return; if (projectId == null) return;

View File

@ -1,54 +1,100 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/project_model.dart'; import 'package:marco/controller/project_controller.dart';
final Logger log = Logger(); final Logger log = Logger();
class DashboardController extends GetxController { class DashboardController extends GetxController {
RxList<ProjectModel> projects = <ProjectModel>[].obs; // Observables
RxString? selectedProjectId; final RxList<Map<String, dynamic>> roleWiseData = <Map<String, dynamic>>[].obs;
var isProjectListExpanded = false.obs; final RxBool isLoading = false.obs;
RxBool isProjectSelectionExpanded = true.obs; final RxString selectedRange = '7D'.obs;
final RxBool isChartView = true.obs;
void toggleProjectListExpanded() { // Inject the ProjectController
isProjectListExpanded.value = !isProjectListExpanded.value; final ProjectController projectController = Get.find<ProjectController>();
}
var isProjectDropdownExpanded = false.obs;
RxBool isLoading = true.obs;
RxBool isLoadingProjects = true.obs;
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
fetchProjects();
final selectedProjectIdRx = projectController.selectedProjectId;
if (selectedProjectIdRx != null) {
// Fix: explicitly cast and use ever<T> with non-nullable type
ever<String>(selectedProjectIdRx, (id) {
if (id.isNotEmpty) {
fetchRoleWiseAttendance();
}
});
// Initial load if already has value
if (selectedProjectIdRx.value.isNotEmpty) {
fetchRoleWiseAttendance();
}
} else {
log.w('selectedProjectId observable is null in ProjectController.');
} }
/// Fetches projects and initializes selected project. ever(selectedRange, (_) {
Future<void> fetchProjects() async { fetchRoleWiseAttendance();
isLoadingProjects.value = true; });
}
int get rangeDays => _getDaysFromRange(selectedRange.value);
int _getDaysFromRange(String range) {
switch (range) {
case '15D':
return 15;
case '30D':
return 30;
case '7D':
default:
return 7;
}
}
void updateRange(String range) {
selectedRange.value = range;
}
void toggleChartView(bool isChart) {
isChartView.value = isChart;
}
Future<void> refreshDashboard() async {
await fetchRoleWiseAttendance();
}
Future<void> fetchRoleWiseAttendance() async {
final String? projectId = projectController.selectedProjectId?.value;
if (projectId == null || projectId.isEmpty) {
log.w('Project ID is null or empty, skipping API call.');
return;
}
try {
isLoading.value = true; isLoading.value = true;
final response = await ApiService.getProjects(); final List<dynamic>? response =
await ApiService.getDashboardAttendanceOverview(projectId, rangeDays);
if (response != null && response.isNotEmpty) { if (response != null) {
projects.assignAll( roleWiseData.value =
response.map((json) => ProjectModel.fromJson(json)).toList()); response.map((e) => Map<String, dynamic>.from(e)).toList();
selectedProjectId = RxString(projects.first.id.toString()); log.i('Attendance overview fetched successfully.');
log.i("Projects fetched: ${projects.length}");
} else { } else {
log.w("No projects found or API call failed."); log.e('Failed to fetch attendance overview: response is null.');
roleWiseData.clear();
} }
} catch (e, st) {
isLoadingProjects.value = false; log.e('Error fetching attendance overview', error: e, stackTrace: st);
roleWiseData.clear();
} finally {
isLoading.value = false; isLoading.value = false;
update(['dashboard_controller']); }
}
void updateSelectedProject(String projectId) {
selectedProjectId?.value = projectId;
} }
} }

View File

@ -1,13 +1,13 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/project_model.dart'; import 'package:marco/model/global_project_model.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/storage/local_storage.dart';
final Logger log = Logger(); final Logger log = Logger();
class ProjectController extends GetxController { class ProjectController extends GetxController {
RxList<ProjectModel> projects = <ProjectModel>[].obs; RxList<GlobalProjectModel> projects = <GlobalProjectModel>[].obs;
RxString? selectedProjectId; RxString? selectedProjectId;
RxBool isProjectListExpanded = false.obs; RxBool isProjectListExpanded = false.obs;
RxBool isProjectSelectionExpanded = false.obs; RxBool isProjectSelectionExpanded = false.obs;
@ -16,7 +16,7 @@ class ProjectController extends GetxController {
RxBool isLoading = true.obs; RxBool isLoading = true.obs;
RxBool isLoadingProjects = true.obs; RxBool isLoadingProjects = true.obs;
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs; RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
ProjectModel? get selectedProject { GlobalProjectModel? get selectedProject {
if (selectedProjectId == null || selectedProjectId!.value.isEmpty) if (selectedProjectId == null || selectedProjectId!.value.isEmpty)
return null; return null;
return projects.firstWhereOrNull((p) => p.id == selectedProjectId!.value); return projects.firstWhereOrNull((p) => p.id == selectedProjectId!.value);
@ -50,7 +50,7 @@ class ProjectController extends GetxController {
if (response != null && response.isNotEmpty) { if (response != null && response.isNotEmpty) {
projects.assignAll( projects.assignAll(
response.map((json) => ProjectModel.fromJson(json)).toList()); response.map((json) => GlobalProjectModel.fromJson(json)).toList());
String? savedId = LocalStorage.getString('selectedProjectId'); String? savedId = LocalStorage.getString('selectedProjectId');
if (savedId != null && projects.any((p) => p.id == savedId)) { if (savedId != null && projects.any((p) => p.id == savedId)) {

View File

@ -0,0 +1,159 @@
import 'package:get/get.dart';
import 'package:logger/logger.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/dailyTaskPlaning/master_work_category_model.dart';
final Logger log = Logger();
class AddTaskController extends GetxController {
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
MyFormValidator basicValidator = MyFormValidator();
RxnString selectedCategoryId = RxnString();
RxnString selectedCategoryName = RxnString();
var categoryIdNameMap = <String, String>{}.obs;
List<Map<String, dynamic>> roles = [];
RxnString selectedRoleId = RxnString();
RxBool isLoadingWorkMasterCategories = false.obs;
RxList<WorkCategoryModel> workMasterCategories = <WorkCategoryModel>[].obs;
RxBool isLoading = false.obs;
@override
void onInit() {
super.onInit();
fetchWorkMasterCategories();
}
String? formFieldValidator(String? value, {required String fieldType}) {
if (value == null || value.trim().isEmpty) {
return 'This field is required';
}
if (fieldType == "target") {
if (int.tryParse(value.trim()) == null) {
return 'Please enter a valid number';
}
}
if (fieldType == "description") {
if (value.trim().length < 5) {
return 'Description must be at least 5 characters';
}
}
return null;
}
Future<bool> assignDailyTask({
required String workItemId,
required int plannedTask,
required String description,
required List<String> taskTeam,
DateTime? assignmentDate,
}) async {
logger.i("Starting assign task...");
final response = await ApiService.assignDailyTask(
workItemId: workItemId,
plannedTask: plannedTask,
description: description,
taskTeam: taskTeam,
assignmentDate: assignmentDate,
);
if (response == true) {
logger.i("Task assigned successfully.");
showAppSnackbar(
title: "Success",
message: "Task assigned successfully!",
type: SnackbarType.success,
);
return true;
} else {
logger.e("Failed to assign task.");
showAppSnackbar(
title: "Error",
message: "Failed to assign task.",
type: SnackbarType.error,
);
return false;
}
}
Future<bool> createTask({
required String parentTaskId,
required String workAreaId,
required String activityId,
required int plannedTask,
required String comment,
required String categoryId,
DateTime? assignmentDate,
}) async {
logger.i("Creating new task...");
final response = await ApiService.createTask(
parentTaskId: parentTaskId,
plannedTask: plannedTask,
comment: comment,
workAreaId: workAreaId,
activityId: activityId,
assignmentDate: assignmentDate,
categoryId: categoryId,
);
if (response == true) {
logger.i("Task created successfully.");
showAppSnackbar(
title: "Success",
message: "Task created successfully!",
type: SnackbarType.success,
);
return true;
} else {
logger.e("Failed to create task.");
showAppSnackbar(
title: "Error",
message: "Failed to create task.",
type: SnackbarType.error,
);
return false;
}
}
Future<void> fetchWorkMasterCategories() async {
isLoadingWorkMasterCategories.value = true;
final response = await ApiService.getMasterWorkCategories();
if (response != null) {
try {
final dataList = response['data'] ?? [];
final parsedList = List<WorkCategoryModel>.from(
dataList.map((e) => WorkCategoryModel.fromJson(e)),
);
workMasterCategories.assignAll(parsedList);
final Map<String, String> mapped = {
for (var item in parsedList) item.id: item.name,
};
categoryIdNameMap.assignAll(mapped);
logger.i("Work categories fetched: ${dataList.length}");
} catch (e) {
logger.e("Error parsing work categories: $e");
workMasterCategories.clear();
categoryIdNameMap.clear();
}
} else {
logger.w("No work categories found or API call failed.");
}
isLoadingWorkMasterCategories.value = false;
update();
}
void selectCategory(String id) {
selectedCategoryId.value = id;
selectedCategoryName.value = categoryIdNameMap[id];
}
}

View File

@ -0,0 +1,308 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:logger/logger.dart';
import 'package:marco/controller/my_controller.dart';
import 'package:marco/controller/task_planing/daily_task_planing_controller.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_image_compressor.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/dailyTaskPlaning/work_status_model.dart';
final Logger logger = Logger();
enum ApiStatus { idle, loading, success, failure }
class ReportTaskActionController extends MyController {
//
// Reactive State
//
final RxBool isLoading = false.obs;
final Rx<ApiStatus> reportStatus = ApiStatus.idle.obs;
final Rx<ApiStatus> commentStatus = ApiStatus.idle.obs;
final RxList<File> selectedImages = <File>[].obs;
final RxList<WorkStatus> workStatus = <WorkStatus>[].obs;
final RxList<WorkStatus> workStatuses = <WorkStatus>[].obs;
final RxBool showAddTaskCheckbox = false.obs;
final RxBool isAddTaskChecked = false.obs;
final RxBool isLoadingWorkStatus = false.obs;
final Rxn<Map<String, dynamic>> selectedTask = Rxn<Map<String, dynamic>>();
final RxString selectedWorkStatusName = ''.obs;
//
// Controllers & Validators
//
final MyFormValidator basicValidator = MyFormValidator();
final DailyTaskPlaningController taskController = Get.put(DailyTaskPlaningController());
final ImagePicker _picker = ImagePicker();
final assignedDateController = TextEditingController();
final workAreaController = TextEditingController();
final activityController = TextEditingController();
final teamSizeController = TextEditingController();
final taskIdController = TextEditingController();
final assignedController = TextEditingController();
final completedWorkController = TextEditingController();
final commentController = TextEditingController();
final assignedByController = TextEditingController();
final teamMembersController = TextEditingController();
final plannedWorkController = TextEditingController();
final approvedTaskController = TextEditingController();
List<TextEditingController> get _allControllers => [
assignedDateController,
workAreaController,
activityController,
teamSizeController,
taskIdController,
assignedController,
completedWorkController,
commentController,
assignedByController,
teamMembersController,
plannedWorkController,
approvedTaskController,
];
//
// Lifecycle Hooks
//
@override
void onInit() {
super.onInit();
logger.i("Initializing ReportTaskController...");
_initializeFormFields();
}
@override
void onClose() {
for (final controller in _allControllers) {
controller.dispose();
}
super.onClose();
}
//
// Form Field Setup
//
void _initializeFormFields() {
basicValidator
..addField('assigned_date', label: "Assigned Date", controller: assignedDateController)
..addField('work_area', label: "Work Area", controller: workAreaController)
..addField('activity', label: "Activity", controller: activityController)
..addField('team_size', label: "Team Size", controller: teamSizeController)
..addField('task_id', label: "Task Id", controller: taskIdController)
..addField('assigned', label: "Assigned", controller: assignedController)
..addField('completed_work', label: "Completed Work", required: true, controller: completedWorkController)
..addField('comment', label: "Comment", required: true, controller: commentController)
..addField('assigned_by', label: "Assigned By", controller: assignedByController)
..addField('team_members', label: "Team Members", controller: teamMembersController)
..addField('planned_work', label: "Planned Work", controller: plannedWorkController)
..addField('approved_task', label: "Approved Task", required: true, controller: approvedTaskController);
}
//
// Task Approval Logic
//
Future<bool> approveTask({
required String projectId,
required String comment,
required String reportActionId,
required String approvedTaskCount,
List<File>? images,
}) async {
logger.i("Starting task approval...");
logger.i("Project ID: $projectId");
logger.i("Comment: $comment");
logger.i("Report Action ID: $reportActionId");
logger.i("Approved Task Count: $approvedTaskCount");
if (projectId.isEmpty || reportActionId.isEmpty) {
_showError("Project ID and Report Action ID are required.");
return false;
}
final approvedTaskInt = int.tryParse(approvedTaskCount);
final completedWorkInt = int.tryParse(completedWorkController.text.trim());
if (approvedTaskInt == null) {
_showError("Invalid approved task count.");
return false;
}
if (completedWorkInt != null && approvedTaskInt > completedWorkInt) {
_showError("Approved task count cannot exceed completed work.");
return false;
}
if (comment.trim().isEmpty) {
_showError("Comment is required.");
return false;
}
try {
reportStatus.value = ApiStatus.loading;
isLoading.value = true;
final imageData = await _prepareImages(images);
final success = await ApiService.approveTask(
id: projectId,
workStatus: reportActionId,
approvedTask: approvedTaskInt,
comment: comment,
images: imageData,
);
if (success) {
_showSuccess("Task approved successfully!");
await taskController.fetchTaskData(projectId);
return true;
} else {
_showError("Failed to approve task.");
return false;
}
} catch (e) {
logger.e("Error approving task: $e");
_showError("An error occurred.");
return false;
} finally {
isLoading.value = false;
Future.delayed(const Duration(milliseconds: 500), () {
reportStatus.value = ApiStatus.idle;
});
}
}
//
// Comment Task Logic
//
Future<void> commentTask({
required String projectId,
required String comment,
List<File>? images,
}) async {
logger.i("Starting task comment...");
if (commentController.text.trim().isEmpty) {
_showError("Comment is required.");
return;
}
try {
isLoading.value = true;
final imageData = await _prepareImages(images);
final success = await ApiService.commentTask(
id: projectId,
comment: commentController.text.trim(),
images: imageData,
).timeout(const Duration(seconds: 30), onTimeout: () {
throw Exception("Request timed out.");
});
if (success) {
_showSuccess("Task commented successfully!");
await taskController.fetchTaskData(projectId);
} else {
_showError("Failed to comment task.");
}
} catch (e) {
logger.e("Error commenting task: $e");
_showError("An error occurred while commenting the task.");
} finally {
isLoading.value = false;
}
}
//
// API Helpers
//
Future<void> fetchWorkStatuses() async {
isLoadingWorkStatus.value = true;
final response = await ApiService.getWorkStatus();
if (response != null) {
final model = WorkStatusResponseModel.fromJson(response);
workStatus.assignAll(model.data);
} else {
logger.w("No work statuses found or API call failed.");
}
isLoadingWorkStatus.value = false;
update(['dashboard_controller']);
}
Future<List<Map<String, dynamic>>?> _prepareImages(List<File>? images) async {
if (images == null || images.isEmpty) return null;
final results = await Future.wait(images.map((file) async {
final compressedBytes = await compressImageToUnder100KB(file);
if (compressedBytes == null) return null;
return {
"fileName": file.path.split('/').last,
"base64Data": base64Encode(compressedBytes),
"contentType": _getContentTypeFromFileName(file.path),
"fileSize": compressedBytes.lengthInBytes,
"description": "Image uploaded for task",
};
}));
return results.whereType<Map<String, dynamic>>().toList();
}
String _getContentTypeFromFileName(String fileName) {
final ext = fileName.split('.').last.toLowerCase();
return switch (ext) {
'jpg' || 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
'gif' => 'image/gif',
_ => 'application/octet-stream',
};
}
//
// Image Picker Utils
//
Future<void> pickImages({required bool fromCamera}) async {
if (fromCamera) {
final pickedFile = await _picker.pickImage(
source: ImageSource.camera,
imageQuality: 75,
);
if (pickedFile != null) {
selectedImages.add(File(pickedFile.path));
}
} else {
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path)));
}
}
void removeImageAt(int index) {
if (index >= 0 && index < selectedImages.length) {
selectedImages.removeAt(index);
}
}
//
// Snackbar Feedback
//
void _showError(String message) => showAppSnackbar(
title: "Error", message: message, type: SnackbarType.error);
void _showSuccess(String message) => showAppSnackbar(
title: "Success", message: message, type: SnackbarType.success);
}

View File

@ -309,7 +309,7 @@ class ReportTaskController extends MyController {
} }
} else { } else {
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75); final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
if (pickedFiles != null && pickedFiles.isNotEmpty) { if (pickedFiles.isNotEmpty) {
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path))); selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path)));
} }
} }

View File

@ -2,6 +2,9 @@ class ApiEndpoints {
static const String baseUrl = "https://stageapi.marcoaiot.com/api"; static const String baseUrl = "https://stageapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api";
// Dashboard Screen API Endpoints
static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview";
// Attendance Screen API Endpoints // Attendance Screen API Endpoints
static const String getProjects = "/project/list"; static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic"; static const String getGlobalProjects = "/project/list/basic";
@ -24,4 +27,8 @@ class ApiEndpoints {
static const String commentTask = "/task/comment"; static const String commentTask = "/task/comment";
static const String dailyTaskDetails = "/project/details"; static const String dailyTaskDetails = "/project/details";
static const String assignDailyTask = "/task/assign"; static const String assignDailyTask = "/task/assign";
static const String getWorkStatus = "/master/work-status";
static const String approveReportAction = "/task/approve";
static const String assignTask = "/project/task";
static const String getmasterWorkCategories = "/Master/work-categories";
} }

View File

@ -11,8 +11,9 @@ import 'package:marco/helpers/services/storage/local_storage.dart';
final Logger logger = Logger(); final Logger logger = Logger();
class ApiService { class ApiService {
static const Duration timeout = Duration(seconds: 10); static const Duration timeout = Duration(seconds: 30);
static const bool enableLogs = true; static const bool enableLogs = true;
static const Duration extendedTimeout = Duration(seconds: 60);
// === Helpers === // === Helpers ===
@ -45,20 +46,29 @@ class ApiService {
return null; return null;
} }
static dynamic _parseResponseForAllData(http.Response response, {String label = ''}) { static dynamic _parseResponseForAllData(http.Response response,
{String label = ''}) {
_log("$label Response: ${response.body}"); _log("$label Response: ${response.body}");
try { try {
final json = jsonDecode(response.body); final body = response.body.trim();
if (body.isEmpty) throw FormatException("Empty response body");
final json = jsonDecode(body);
if (response.statusCode == 200 && json['success'] == true) { if (response.statusCode == 200 && json['success'] == true) {
return json; return json;
} }
_log("API Error [$label]: ${json['message'] ?? 'Unknown error'}"); _log("API Error [$label]: ${json['message'] ?? 'Unknown error'}");
} catch (e) { } catch (e) {
_log("Response parsing error [$label]: $e"); _log("Response parsing error [$label]: $e");
} }
return null; return null;
} }
static Future<http.Response?> _getRequest( static Future<http.Response?> _getRequest(
String endpoint, { String endpoint, {
Map<String, String>? queryParams, Map<String, String>? queryParams,
@ -67,15 +77,18 @@ class ApiService {
String? token = await _getToken(); String? token = await _getToken();
if (token == null) return null; if (token == null) return null;
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint").replace(queryParameters: queryParams); final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint")
.replace(queryParameters: queryParams);
_log("GET $uri"); _log("GET $uri");
try { try {
final response = await http.get(uri, headers: _headers(token)).timeout(timeout); final response =
await http.get(uri, headers: _headers(token)).timeout(timeout);
if (response.statusCode == 401 && !hasRetried) { if (response.statusCode == 401 && !hasRetried) {
_log("Unauthorized. Attempting token refresh..."); _log("Unauthorized. Attempting token refresh...");
if (await AuthService.refreshToken()) { if (await AuthService.refreshToken()) {
return await _getRequest(endpoint, queryParams: queryParams, hasRetried: true); return await _getRequest(endpoint,
queryParams: queryParams, hasRetried: true);
} }
_log("Token refresh failed."); _log("Token refresh failed.");
} }
@ -106,7 +119,8 @@ class ApiService {
if (response.statusCode == 401 && !hasRetried) { if (response.statusCode == 401 && !hasRetried) {
_log("Unauthorized POST. Attempting token refresh..."); _log("Unauthorized POST. Attempting token refresh...");
if (await AuthService.refreshToken()) { if (await AuthService.refreshToken()) {
return await _postRequest(endpoint, body, customTimeout: customTimeout, hasRetried: true); return await _postRequest(endpoint, body,
customTimeout: customTimeout, hasRetried: true);
} }
} }
return response; return response;
@ -116,17 +130,36 @@ class ApiService {
} }
} }
// === Dashboard Endpoints ===
static Future<List<dynamic>?> getDashboardAttendanceOverview(
String projectId, int days) async {
if (projectId.isEmpty) throw ArgumentError('projectId must not be empty');
if (days <= 0) throw ArgumentError('days must be greater than 0');
final endpoint =
"${ApiEndpoints.getDashboardAttendanceOverview}/$projectId?days=$days";
return _getRequest(endpoint).then((res) => res != null
? _parseResponse(res, label: 'Dashboard Attendance Overview')
: null);
}
// === Attendance APIs === // === Attendance APIs ===
static Future<List<dynamic>?> getProjects() async => static Future<List<dynamic>?> getProjects() async =>
_getRequest(ApiEndpoints.getProjects).then((res) => res != null ? _parseResponse(res, label: 'Projects') : null); _getRequest(ApiEndpoints.getProjects).then(
(res) => res != null ? _parseResponse(res, label: 'Projects') : null);
static Future<List<dynamic>?> getGlobalProjects() async => static Future<List<dynamic>?> getGlobalProjects() async =>
_getRequest(ApiEndpoints.getProjects).then((res) => res != null ? _parseResponse(res, label: 'Global Projects') : null); _getRequest(ApiEndpoints.getGlobalProjects).then((res) =>
res != null ? _parseResponse(res, label: 'Global Projects') : null);
static Future<List<dynamic>?> getEmployeesByProject(String projectId) async => static Future<List<dynamic>?> getEmployeesByProject(String projectId) async =>
_getRequest(ApiEndpoints.getEmployeesByProject, queryParams: {"projectId": projectId}) _getRequest(ApiEndpoints.getEmployeesByProject,
.then((res) => res != null ? _parseResponse(res, label: 'Employees') : null); queryParams: {"projectId": projectId})
.then((res) =>
res != null ? _parseResponse(res, label: 'Employees') : null);
static Future<List<dynamic>?> getAttendanceLogs( static Future<List<dynamic>?> getAttendanceLogs(
String projectId, { String projectId, {
@ -135,20 +168,25 @@ class ApiService {
}) async { }) async {
final query = { final query = {
"projectId": projectId, "projectId": projectId,
if (dateFrom != null) "dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom), if (dateFrom != null)
"dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom),
if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo), if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo),
}; };
return _getRequest(ApiEndpoints.getAttendanceLogs, queryParams: query) return _getRequest(ApiEndpoints.getAttendanceLogs, queryParams: query).then(
.then((res) => res != null ? _parseResponse(res, label: 'Attendance Logs') : null); (res) =>
res != null ? _parseResponse(res, label: 'Attendance Logs') : null);
} }
static Future<List<dynamic>?> getAttendanceLogView(String id) async => static Future<List<dynamic>?> getAttendanceLogView(String id) async =>
_getRequest("${ApiEndpoints.getAttendanceLogView}/$id") _getRequest("${ApiEndpoints.getAttendanceLogView}/$id").then((res) =>
.then((res) => res != null ? _parseResponse(res, label: 'Log Details') : null); res != null ? _parseResponse(res, label: 'Log Details') : null);
static Future<List<dynamic>?> getRegularizationLogs(String projectId) async => static Future<List<dynamic>?> getRegularizationLogs(String projectId) async =>
_getRequest(ApiEndpoints.getRegularizationLogs, queryParams: {"projectId": projectId}) _getRequest(ApiEndpoints.getRegularizationLogs,
.then((res) => res != null ? _parseResponse(res, label: 'Regularization Logs') : null); queryParams: {"projectId": projectId})
.then((res) => res != null
? _parseResponse(res, label: 'Regularization Logs')
: null);
static Future<bool> uploadAttendanceImage( static Future<bool> uploadAttendanceImage(
String id, String id,
@ -194,7 +232,11 @@ class ApiService {
} }
} }
final response = await _postRequest(ApiEndpoints.uploadAttendanceImage, body); final response = await _postRequest(
ApiEndpoints.uploadAttendanceImage,
body,
customTimeout: extendedTimeout,
);
if (response == null) return false; if (response == null) return false;
final json = jsonDecode(response.body); final json = jsonDecode(response.body);
@ -213,17 +255,22 @@ class ApiService {
// === Employee APIs === // === Employee APIs ===
static Future<List<dynamic>?> getAllEmployeesByProject(String projectId) async { static Future<List<dynamic>?> getAllEmployeesByProject(
String projectId) async {
if (projectId.isEmpty) throw ArgumentError('projectId must not be empty'); if (projectId.isEmpty) throw ArgumentError('projectId must not be empty');
final endpoint = "${ApiEndpoints.getAllEmployeesByProject}/$projectId"; final endpoint = "${ApiEndpoints.getAllEmployeesByProject}/$projectId";
return _getRequest(endpoint).then((res) => res != null ? _parseResponse(res, label: 'Employees by Project') : null); return _getRequest(endpoint).then((res) => res != null
? _parseResponse(res, label: 'Employees by Project')
: null);
} }
static Future<List<dynamic>?> getAllEmployees() async => static Future<List<dynamic>?> getAllEmployees() async =>
_getRequest(ApiEndpoints.getAllEmployees).then((res) => res != null ? _parseResponse(res, label: 'All Employees') : null); _getRequest(ApiEndpoints.getAllEmployees).then((res) =>
res != null ? _parseResponse(res, label: 'All Employees') : null);
static Future<List<dynamic>?> getRoles() async => static Future<List<dynamic>?> getRoles() async =>
_getRequest(ApiEndpoints.getRoles).then((res) => res != null ? _parseResponse(res, label: 'Roles') : null); _getRequest(ApiEndpoints.getRoles).then(
(res) => res != null ? _parseResponse(res, label: 'Roles') : null);
static Future<bool> createEmployee({ static Future<bool> createEmployee({
required String firstName, required String firstName,
@ -239,16 +286,24 @@ class ApiService {
"gender": gender, "gender": gender,
"jobRoleId": jobRoleId, "jobRoleId": jobRoleId,
}; };
final response = await _postRequest(ApiEndpoints.createEmployee, body); final response = await _postRequest(
ApiEndpoints.reportTask,
body,
customTimeout: extendedTimeout,
);
if (response == null) return false; if (response == null) return false;
final json = jsonDecode(response.body); final json = jsonDecode(response.body);
return response.statusCode == 200 && json['success'] == true; return response.statusCode == 200 && json['success'] == true;
} }
static Future<Map<String, dynamic>?> getEmployeeDetails(String employeeId) async { static Future<Map<String, dynamic>?> getEmployeeDetails(
String employeeId) async {
final url = "${ApiEndpoints.getEmployeeInfo}/$employeeId"; final url = "${ApiEndpoints.getEmployeeInfo}/$employeeId";
final response = await _getRequest(url); final response = await _getRequest(url);
final data = response != null ? _parseResponse(response, label: 'Employee Details') : null; final data = response != null
? _parseResponse(response, label: 'Employee Details')
: null;
return data is Map<String, dynamic> ? data : null; return data is Map<String, dynamic> ? data : null;
} }
@ -261,11 +316,13 @@ class ApiService {
}) async { }) async {
final query = { final query = {
"projectId": projectId, "projectId": projectId,
if (dateFrom != null) "dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom), if (dateFrom != null)
"dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom),
if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo), if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo),
}; };
return _getRequest(ApiEndpoints.getDailyTask, queryParams: query) return _getRequest(ApiEndpoints.getDailyTask, queryParams: query).then(
.then((res) => res != null ? _parseResponse(res, label: 'Daily Tasks') : null); (res) =>
res != null ? _parseResponse(res, label: 'Daily Tasks') : null);
} }
static Future<bool> reportTask({ static Future<bool> reportTask({
@ -284,7 +341,12 @@ class ApiService {
if (images != null && images.isNotEmpty) "images": images, if (images != null && images.isNotEmpty) "images": images,
}; };
final response = await _postRequest(ApiEndpoints.reportTask, body); final response = await _postRequest(
ApiEndpoints.reportTask,
body,
customTimeout: extendedTimeout,
);
if (response == null) return false; if (response == null) return false;
final json = jsonDecode(response.body); final json = jsonDecode(response.body);
if (response.statusCode == 200 && json['success'] == true) { if (response.statusCode == 200 && json['success'] == true) {
@ -313,11 +375,13 @@ class ApiService {
return response.statusCode == 200 && json['success'] == true; return response.statusCode == 200 && json['success'] == true;
} }
static Future<Map<String, dynamic>?> getDailyTasksDetails(String projectId) async { static Future<Map<String, dynamic>?> getDailyTasksDetails(
String projectId) async {
final url = "${ApiEndpoints.dailyTaskDetails}/$projectId"; final url = "${ApiEndpoints.dailyTaskDetails}/$projectId";
final response = await _getRequest(url); final response = await _getRequest(url);
return response != null return response != null
? _parseResponseForAllData(response, label: 'Daily Task Details') as Map<String, dynamic>? ? _parseResponseForAllData(response, label: 'Daily Task Details')
as Map<String, dynamic>?
: null; : null;
} }
@ -333,7 +397,8 @@ class ApiService {
"plannedTask": plannedTask, "plannedTask": plannedTask,
"description": description, "description": description,
"taskTeam": taskTeam, "taskTeam": taskTeam,
"assignmentDate": (assignmentDate ?? DateTime.now()).toUtc().toIso8601String(), "assignmentDate":
(assignmentDate ?? DateTime.now()).toUtc().toIso8601String(),
}; };
final response = await _postRequest(ApiEndpoints.assignDailyTask, body); final response = await _postRequest(ApiEndpoints.assignDailyTask, body);
if (response == null) return false; if (response == null) return false;
@ -345,4 +410,78 @@ class ApiService {
_log("Failed to assign daily task: ${json['message'] ?? 'Unknown error'}"); _log("Failed to assign daily task: ${json['message'] ?? 'Unknown error'}");
return false; return false;
} }
static Future<Map<String, dynamic>?> getWorkStatus() async {
final res = await _getRequest(ApiEndpoints.getWorkStatus);
if (res == null) {
_log('Work Status API returned null');
return null;
}
_log('Work Status raw response: ${res.body}');
return _parseResponseForAllData(res, label: 'Work Status')
as Map<String, dynamic>?;
}
static Future<Map<String, dynamic>?> getMasterWorkCategories() async =>
_getRequest(ApiEndpoints.getmasterWorkCategories).then((res) =>
res != null
? _parseResponseForAllData(res, label: 'Master Work Categories')
: null);
static Future<bool> approveTask({
required String id,
required String comment,
required String workStatus,
required int approvedTask,
List<Map<String, dynamic>>? images,
}) async {
final body = {
"id": id,
"workStatus": workStatus,
"approvedTask": approvedTask,
"comment": comment,
if (images != null && images.isNotEmpty) "images": images,
};
final response = await _postRequest(ApiEndpoints.approveReportAction, body);
if (response == null) return false;
final json = jsonDecode(response.body);
return response.statusCode == 200 && json['success'] == true;
}
static Future<bool> createTask({
required String parentTaskId,
required int plannedTask,
required String comment,
required String workAreaId,
required String activityId,
DateTime? assignmentDate,
required String categoryId,
}) async {
final body = [
{
"parentTaskId": parentTaskId,
"plannedWork": plannedTask,
"comment": comment,
"workAreaID": workAreaId,
"activityID": activityId,
"workCategoryId": categoryId,
'completedWork': 0,
}
];
final response = await _postRequest(ApiEndpoints.assignTask, body);
if (response == null) return false;
final json = jsonDecode(response.body);
if (response.statusCode == 200 && json['success'] == true) {
Get.back();
return true;
}
_log("Failed to create task: ${json['message'] ?? 'Unknown error'}");
return false;
}
} }

View File

@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/my_card.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/utils/my_shadow.dart';
class SkeletonLoaders {
static Widget buildLoadingSkeleton() {
return SizedBox(
height: 360,
child: Column(
children: List.generate(5, (index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(6, (i) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
width: 48,
height: 16,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
);
}),
),
),
);
}),
),
);
}
// Employee List - Card Style
static Widget employeeListSkeletonLoader() {
return Column(
children: List.generate(4, (index) {
return MyCard.bordered(
borderRadiusAll: 12,
paddingAll: 10,
margin: MySpacing.bottom(12),
shadow: MyShadow(elevation: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar
Container(
width: 41,
height: 41,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(height: 14, width: 100, color: Colors.grey.shade300),
MySpacing.width(8),
Container(height: 12, width: 60, color: Colors.grey.shade300),
],
),
MySpacing.height(8),
Row(
children: [
Icon(Icons.email, size: 16, color: Colors.grey.shade300),
MySpacing.width(4),
Container(height: 10, width: 140, color: Colors.grey.shade300),
],
),
MySpacing.height(8),
Row(
children: [
Icon(Icons.phone, size: 16, color: Colors.grey.shade300),
MySpacing.width(4),
Container(height: 10, width: 100, color: Colors.grey.shade300),
],
),
],
),
),
],
),
);
}),
);
}
// Employee List - Compact Collapsed Style
static Widget employeeListCollapsedSkeletonLoader() {
return MyCard.bordered(
borderRadiusAll: 4,
paddingAll: 8,
child: Column(
children: List.generate(4, (index) {
return Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
// Avatar
Container(
width: 31,
height: 31,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
MySpacing.width(16),
// Name, Designation & Buttons
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 12, width: 100, color: Colors.grey.shade300),
MySpacing.height(8),
Container(height: 10, width: 80, color: Colors.grey.shade300),
MySpacing.height(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Container(height: 28, width: 60, color: Colors.grey.shade300),
MySpacing.width(8),
Container(height: 28, width: 60, color: Colors.grey.shade300),
],
),
],
),
),
],
),
),
if (index != 3)
Divider(
color: Colors.grey.withOpacity(0.3),
thickness: 1,
height: 1,
),
],
);
}),
),
);
}
// Daily Progress Report Header Loader
static Widget dailyProgressReportSkeletonLoader() {
return MyCard.bordered(
borderRadiusAll: 4,
border: Border.all(color: Colors.grey.withOpacity(0.2)),
shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom),
paddingAll: 8,
child: Column(
children: List.generate(3, (index) {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(height: 14, width: 120, color: Colors.grey.shade300),
Icon(Icons.add_circle, color: Colors.grey.shade300),
],
),
if (index != 2) ...[
MySpacing.height(12),
Divider(color: Colors.grey.withOpacity(0.3), thickness: 1),
MySpacing.height(12),
],
],
);
}),
),
);
}
// Daily Progress Planning (Collapsed View)
static Widget dailyProgressPlanningSkeletonCollapsedOnly() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(3, (index) {
return MyCard.bordered(
borderRadiusAll: 12,
paddingAll: 16,
margin: MySpacing.bottom(12),
shadow: MyShadow(elevation: 3),
child: Row(
children: [
// Icon placeholder
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
MySpacing.width(12),
// Text line
Expanded(
child: Container(height: 16, color: Colors.grey.shade300),
),
MySpacing.width(12),
// Expand button placeholder
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.grey.shade300,
),
),
],
),
);
}),
);
}
}

View File

@ -6,8 +6,8 @@ class AttendanceModel {
final DateTime startDate; final DateTime startDate;
final DateTime endDate; final DateTime endDate;
final int teamSize; final int teamSize;
final int completedWork; final double completedWork;
final int plannedWork; final double plannedWork;
AttendanceModel({ AttendanceModel({
required this.id, required this.id,
@ -30,8 +30,8 @@ class AttendanceModel {
startDate: DateTime.tryParse(json['startDate']?.toString() ?? '') ?? DateTime.now(), startDate: DateTime.tryParse(json['startDate']?.toString() ?? '') ?? DateTime.now(),
endDate: DateTime.tryParse(json['endDate']?.toString() ?? '') ?? DateTime.now(), endDate: DateTime.tryParse(json['endDate']?.toString() ?? '') ?? DateTime.now(),
teamSize: int.tryParse(json['teamSize']?.toString() ?? '') ?? 0, teamSize: int.tryParse(json['teamSize']?.toString() ?? '') ?? 0,
completedWork: int.tryParse(json['completedWork']?.toString() ?? '') ?? 0, completedWork: double.tryParse(json['completedWork']?.toString() ?? '') ?? 0,
plannedWork: int.tryParse(json['plannedWork']?.toString() ?? '') ?? 0, plannedWork: double.tryParse(json['plannedWork']?.toString() ?? '') ?? 0,
); );
} }

View File

@ -11,6 +11,7 @@ import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/widgets/my_team_model_sheet.dart'; import 'package:marco/helpers/widgets/my_team_model_sheet.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'dart:io';
class CommentTaskBottomSheet extends StatefulWidget { class CommentTaskBottomSheet extends StatefulWidget {
final Map<String, dynamic> taskData; final Map<String, dynamic> taskData;
@ -182,87 +183,12 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
if ((widget.taskData['reportedPreSignedUrls'] if ((widget.taskData['reportedPreSignedUrls']
as List<dynamic>?) as List<dynamic>?)
?.isNotEmpty == ?.isNotEmpty ==
true) ...[ true)
MySpacing.height(8), buildReportedImagesSection(
Padding( imageUrls: List<String>.from(
padding: widget.taskData['reportedPreSignedUrls'] ?? []),
const EdgeInsets.symmetric(horizontal: 0.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.image_outlined,
size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall(
"Reported Images",
fontWeight: 600,
),
],
),
),
MySpacing.height(8),
Builder(
builder: (context) {
final allImageUrls = List<String>.from(
widget.taskData['reportedPreSignedUrls'] ?? [],
);
if (allImageUrls.isEmpty) return const SizedBox();
return Padding(
padding: const EdgeInsets.symmetric(
horizontal:
16.0), // Same horizontal padding
child: SizedBox(
height: 70,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: allImageUrls.length,
separatorBuilder: (_, __) =>
const SizedBox(width: 12),
itemBuilder: (context, index) {
final url = allImageUrls[index];
return GestureDetector(
onTap: () {
showDialog(
context: context, context: context,
barrierColor: Colors.black54,
builder: (_) => ImageViewerDialog(
imageSources: allImageUrls,
initialIndex: index,
), ),
);
},
child: ClipRRect(
borderRadius:
BorderRadius.circular(12),
child: Image.network(
url,
width: 70,
height: 70,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
Container(
width: 70,
height: 70,
color: Colors.grey.shade200,
child: Icon(Icons.broken_image,
color: Colors.grey[600]),
),
),
),
);
},
),
),
);
},
),
MySpacing.height(16),
],
Row( Row(
children: [ children: [
Icon(Icons.comment_outlined, Icon(Icons.comment_outlined,
@ -313,145 +239,31 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
), ),
Obx(() { Obx(() {
final images = controller.selectedImages; final images = controller.selectedImages;
return Column(
crossAxisAlignment: CrossAxisAlignment.start, return buildImagePickerSection(
children: [ images: images,
if (images.isEmpty) onCameraTap: () =>
Container( controller.pickImages(fromCamera: true),
height: 70, onUploadTap: () =>
width: double.infinity, controller.pickImages(fromCamera: false),
decoration: BoxDecoration( onRemoveImage: (index) =>
borderRadius: BorderRadius.circular(12), controller.removeImageAt(index),
border: Border.all( onPreviewImage: (index) {
color: Colors.grey.shade300, width: 2),
color: Colors.grey.shade100,
),
child: Center(
child: Icon(Icons.photo_camera_outlined,
size: 48, color: Colors.grey.shade400),
),
)
else
SizedBox(
height: 70,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: images.length,
separatorBuilder: (context, index) =>
SizedBox(height: 12),
itemBuilder: (context, index) {
final file = images[index];
return Stack(
children: [
GestureDetector(
onTap: () {
showDialog( showDialog(
context: context, context: context,
builder: (_) => builder: (_) => ImageViewerDialog(
ImageViewerDialog(
imageSources: images, imageSources: images,
initialIndex: index, initialIndex: index,
), ),
); );
}, },
child: ClipRRect(
borderRadius:
BorderRadius.circular(12),
child: Image.file(
file,
height: 70,
width: 70,
fit: BoxFit.cover,
),
),
),
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: () => controller
.removeImageAt(index),
child: Container(
decoration: BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: Icon(Icons.close,
size: 20,
color: Colors.white),
),
),
),
],
);
},
),
),
MySpacing.height(16),
Row(
children: [
Expanded(
child: MyButton.outlined(
onPressed: () => controller.pickImages(
fromCamera: true),
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.camera_alt,
size: 16,
color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Capture',
color: Colors.blueAccent),
],
),
),
),
MySpacing.width(12),
Expanded(
child: MyButton.outlined(
onPressed: () => controller.pickImages(
fromCamera: false),
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.upload_file,
size: 16,
color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Upload',
color: Colors.blueAccent),
],
),
),
),
],
),
],
); );
}), }),
MySpacing.height(24), MySpacing.height(24),
Row( buildCommentActionButtons(
mainAxisAlignment: MainAxisAlignment.end, onCancel: () => Navigator.of(context).pop(),
children: [ onSubmit: () async {
MyButton.text( if (controller.basicValidator.validateForm()) {
onPressed: () => Navigator.of(context).pop(),
padding: MySpacing.xy(20, 16),
splashColor: contentTheme.secondary.withAlpha(25),
child: MyText.bodySmall('Cancel'),
),
MySpacing.width(12),
Obx(() {
return MyButton(
onPressed: controller.isLoading.value
? null
: () async {
if (controller.basicValidator
.validateForm()) {
await controller.commentTask( await controller.commentTask(
projectId: controller.basicValidator projectId: controller.basicValidator
.getController('task_id') .getController('task_id')
@ -463,34 +275,15 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
'', '',
images: controller.selectedImages, images: controller.selectedImages,
); );
if (widget.onCommentSuccess != null) { if (widget.onCommentSuccess != null) {
widget.onCommentSuccess!(); widget.onCommentSuccess!();
} }
} }
}, },
elevation: 0, isLoading: controller.isLoading,
padding: MySpacing.xy(20, 16), splashColor: contentTheme.secondary.withAlpha(25),
backgroundColor: Colors.blueAccent, backgroundColor: Colors.blueAccent,
borderRadiusAll: AppStyle.buttonRadius.medium, loadingIndicatorColor: contentTheme.onPrimary,
child: controller.isLoading.value
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(
contentTheme.onPrimary),
),
)
: MyText.bodySmall(
'Comment',
color: contentTheme.onPrimary,
),
);
}),
],
), ),
MySpacing.height(10), MySpacing.height(10),
if ((widget.taskData['taskComments'] as List<dynamic>?) if ((widget.taskData['taskComments'] as List<dynamic>?)
@ -508,267 +301,98 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
), ),
], ],
), ),
Divider(),
MySpacing.height(12), MySpacing.height(12),
Builder( Builder(
builder: (context) { builder: (context) {
final comments = List<Map<String, dynamic>>.from( final comments = List<Map<String, dynamic>>.from(
widget.taskData['taskComments'] as List, widget.taskData['taskComments'] as List,
); );
return buildCommentList(comments, context);
},
)
],
],
),
),
);
},
),
],
),
),
);
}
comments.sort((a, b) { Widget buildReportedImagesSection({
final aDate = required List<String> imageUrls,
DateTime.tryParse(a['date'] ?? '') ?? required BuildContext context,
DateTime.fromMillisecondsSinceEpoch(0); String title = "Reported Images",
final bDate = }) {
DateTime.tryParse(b['date'] ?? '') ?? if (imageUrls.isEmpty) return const SizedBox();
DateTime.fromMillisecondsSinceEpoch(0);
return bDate.compareTo(
aDate); // descending: newest first
});
return SizedBox( return Column(
height: 300, crossAxisAlignment: CrossAxisAlignment.start,
child: ListView.builder( children: [
padding: const EdgeInsets.symmetric( MySpacing.height(8),
vertical: Padding(
8), // Added padding around the list padding: const EdgeInsets.symmetric(horizontal: 0.0),
itemCount: comments.length,
itemBuilder: (context, index) {
final comment = comments[index];
final commentText = comment['text'] ?? '-';
final commentedBy =
comment['commentedBy'] ?? 'Unknown';
final relativeTime =
timeAgo(comment['date'] ?? '');
// Dummy image URLs (simulate as if coming from backend)
final imageUrls = List<String>.from(
comment['preSignedUrls'] ?? []);
return Container(
margin: const EdgeInsets.symmetric(
vertical: 8), // Spacing between items
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: Row( child: Row(
crossAxisAlignment: crossAxisAlignment: CrossAxisAlignment.center,
CrossAxisAlignment.start,
children: [ children: [
const SizedBox(width: 12), Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]),
Expanded( MySpacing.width(8),
child: Column( MyText.titleSmall(
crossAxisAlignment: title,
CrossAxisAlignment.start,
children: [
// 🔹 Top Row: Avatar + Name + Time
Row(
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Avatar(
firstName: commentedBy
.split(' ')
.first,
lastName: commentedBy
.split(' ')
.length >
1
? commentedBy
.split(' ')
.last
: '',
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Row(
mainAxisAlignment:
MainAxisAlignment
.spaceBetween,
children: [
MyText.bodyMedium(
commentedBy,
fontWeight: 700,
color:
Colors.black87,
),
MyText.bodySmall(
relativeTime,
fontSize: 12,
color:
Colors.black54,
),
],
),
),
],
),
const SizedBox(height: 12),
// 🔹 Comment text below attachments
Row(
mainAxisAlignment:
MainAxisAlignment
.spaceBetween,
children: [
MyText.bodyMedium(
commentText,
fontWeight: 500,
color: Colors.black87,
),
],
),
const SizedBox(height: 12),
// 🔹 Attachments row: full width below top row
if (imageUrls.isNotEmpty) ...[
Row(
crossAxisAlignment:
CrossAxisAlignment
.start,
children: [
Icon(
Icons
.attach_file_outlined,
size: 18,
color:
Colors.grey[700]),
MyText.bodyMedium(
'Attachments',
fontWeight: 600, fontWeight: 600,
color: Colors.black87,
), ),
], ],
), ),
const SizedBox(height: 8), ),
SizedBox( MySpacing.height(8),
height: 60, Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
height: 70,
child: ListView.separated( child: ListView.separated(
padding: const EdgeInsets scrollDirection: Axis.horizontal,
.symmetric( itemCount: imageUrls.length,
horizontal: 0), separatorBuilder: (_, __) => const SizedBox(width: 12),
scrollDirection: itemBuilder: (context, index) {
Axis.horizontal, final url = imageUrls[index];
itemCount:
imageUrls.length,
itemBuilder: (context,
imageIndex) {
final imageUrl =
imageUrls[
imageIndex];
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
showDialog( showDialog(
context: context, context: context,
barrierColor: barrierColor: Colors.black54,
Colors builder: (_) => ImageViewerDialog(
.black54, imageSources: imageUrls,
builder: (_) => initialIndex: index,
ImageViewerDialog(
imageSources:
imageUrls,
initialIndex:
imageIndex,
), ),
); );
}, },
child: Stack( child: ClipRRect(
children: [ borderRadius: BorderRadius.circular(12),
Container( child: Image.network(
width: 60, url,
height: 60, width: 70,
decoration: height: 70,
BoxDecoration( fit: BoxFit.cover,
borderRadius: errorBuilder: (context, error, stackTrace) => Container(
BorderRadius width: 70,
.circular( height: 70,
12), color: Colors.grey.shade200,
color: Colors
.grey[
100],
boxShadow: [
BoxShadow(
color: Colors
.black26,
blurRadius:
6,
offset:
Offset(
2,
2),
),
],
),
child: child:
ClipRRect( Icon(Icons.broken_image, color: Colors.grey[600]),
borderRadius:
BorderRadius
.circular(
12),
child: Image
.network(
imageUrl,
fit: BoxFit
.cover,
errorBuilder: (context,
error,
stackTrace) =>
Container(
color: Colors
.grey[
300],
child: Icon(
Icons
.broken_image,
color:
Colors.grey[700]),
), ),
), ),
), ),
),
const Positioned(
right: 4,
bottom: 4,
child: Icon(
Icons
.zoom_in,
color: Colors
.white70,
size: 16),
),
],
),
);
},
separatorBuilder:
(_, __) =>
const SizedBox(
width: 12),
),
),
const SizedBox(height: 12),
],
],
),
),
],
),
);
},
),
);
},
),
],
],
),
),
); );
}, },
), ),
),
),
MySpacing.height(16),
], ],
),
),
); );
} }
@ -837,6 +461,53 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
); );
} }
Widget buildCommentActionButtons({
required VoidCallback onCancel,
required Future<void> Function() onSubmit,
required RxBool isLoading,
required Color splashColor,
required Color backgroundColor,
required Color loadingIndicatorColor,
double? buttonHeight,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
MyButton.text(
onPressed: onCancel,
padding: MySpacing.xy(20, 16),
splashColor: splashColor,
child: MyText.bodySmall('Cancel'),
),
MySpacing.width(12),
Obx(() {
return MyButton(
onPressed: isLoading.value ? null : onSubmit,
elevation: 0,
padding: MySpacing.xy(20, 16),
backgroundColor: backgroundColor,
borderRadiusAll: AppStyle.buttonRadius.medium,
child: isLoading.value
? SizedBox(
width: buttonHeight ?? 16,
height: buttonHeight ?? 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
loadingIndicatorColor,
),
),
)
: MyText.bodySmall(
'Comment',
color: loadingIndicatorColor,
),
);
}),
],
);
}
Widget buildRow(String label, String? value, {IconData? icon}) { Widget buildRow(String label, String? value, {IconData? icon}) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
@ -860,4 +531,281 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
), ),
); );
} }
Widget buildCommentList(
List<Map<String, dynamic>> comments, BuildContext context) {
comments.sort((a, b) {
final aDate = DateTime.tryParse(a['date'] ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0);
final bDate = DateTime.tryParse(b['date'] ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0);
return bDate.compareTo(aDate); // newest first
});
return SizedBox(
height: 300,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: comments.length,
itemBuilder: (context, index) {
final comment = comments[index];
final commentText = comment['text'] ?? '-';
final commentedBy = comment['commentedBy'] ?? 'Unknown';
final relativeTime = timeAgo(comment['date'] ?? '');
final imageUrls = List<String>.from(comment['preSignedUrls'] ?? []);
return Container(
margin: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Avatar(
firstName: commentedBy.split(' ').first,
lastName: commentedBy.split(' ').length > 1
? commentedBy.split(' ').last
: '',
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(
commentedBy,
fontWeight: 700,
color: Colors.black87,
),
MyText.bodySmall(
relativeTime,
fontSize: 12,
color: Colors.black54,
),
],
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: MyText.bodyMedium(
commentText,
fontWeight: 500,
color: Colors.black87,
),
),
],
),
const SizedBox(height: 12),
if (imageUrls.isNotEmpty) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.attach_file_outlined,
size: 18, color: Colors.grey[700]),
MyText.bodyMedium(
'Attachments',
fontWeight: 600,
color: Colors.black87,
),
],
),
const SizedBox(height: 8),
SizedBox(
height: 60,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: imageUrls.length,
itemBuilder: (context, imageIndex) {
final imageUrl = imageUrls[imageIndex];
return GestureDetector(
onTap: () {
showDialog(
context: context,
barrierColor: Colors.black54,
builder: (_) => ImageViewerDialog(
imageSources: imageUrls,
initialIndex: imageIndex,
),
);
},
child: Stack(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.grey[100],
boxShadow: [
BoxShadow(
color: Colors.black26,
blurRadius: 6,
offset: Offset(2, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
Container(
color: Colors.grey[300],
child: Icon(Icons.broken_image,
color: Colors.grey[700]),
),
),
),
),
const Positioned(
right: 4,
bottom: 4,
child: Icon(Icons.zoom_in,
color: Colors.white70, size: 16),
),
],
),
);
},
separatorBuilder: (_, __) =>
const SizedBox(width: 12),
),
),
const SizedBox(height: 12),
],
],
),
),
],
),
);
},
),
);
}
Widget buildImagePickerSection({
required List<File> images,
required VoidCallback onCameraTap,
required VoidCallback onUploadTap,
required void Function(int index) onRemoveImage,
required void Function(int initialIndex) onPreviewImage,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (images.isEmpty)
Container(
height: 70,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300, width: 2),
color: Colors.grey.shade100,
),
child: Center(
child: Icon(Icons.photo_camera_outlined,
size: 48, color: Colors.grey.shade400),
),
)
else
SizedBox(
height: 70,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: images.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final file = images[index];
return Stack(
children: [
GestureDetector(
onTap: () => onPreviewImage(index),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
file,
height: 70,
width: 70,
fit: BoxFit.cover,
),
),
),
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: () => onRemoveImage(index),
child: Container(
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(Icons.close,
size: 20, color: Colors.white),
),
),
),
],
);
},
),
),
MySpacing.height(16),
Row(
children: [
Expanded(
child: MyButton.outlined(
onPressed: onCameraTap,
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.camera_alt,
size: 16, color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Capture', color: Colors.blueAccent),
],
),
),
),
MySpacing.width(12),
Expanded(
child: MyButton.outlined(
onPressed: onUploadTap,
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.upload_file,
size: 16, color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Upload', color: Colors.blueAccent),
],
),
),
),
],
),
],
);
}
} }

View File

@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/task_planing/add_task_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:logger/logger.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
final Logger log = Logger();
void showCreateTaskBottomSheet({
required String workArea,
required String activity,
required String completedWork,
required String unit,
required Function(String) onCategoryChanged,
required String parentTaskId,
required int plannedTask,
required String activityId,
required String workAreaId,
required VoidCallback onSubmit,
}) {
final controller = Get.put(AddTaskController());
final TextEditingController plannedTaskController =
TextEditingController(text: plannedTask.toString());
final TextEditingController descriptionController = TextEditingController();
Get.bottomSheet(
StatefulBuilder(
builder: (context, setState) {
return LayoutBuilder(
builder: (context, constraints) {
final isLarge = constraints.maxWidth > 600;
final horizontalPadding =
isLarge ? constraints.maxWidth * 0.2 : 16.0;
return // Inside showManageTaskBottomSheet...
SafeArea(
child: Material(
color: Colors.white,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(20)),
child: Container(
constraints: const BoxConstraints(maxHeight: 760),
padding: EdgeInsets.fromLTRB(
horizontalPadding, 12, horizontalPadding, 24),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
),
),
),
Center(
child: MyText.titleLarge(
"Create Task",
fontWeight: 700,
),
),
const SizedBox(height: 20),
_infoCardSection([
_infoRowWithIcon(
Icons.workspaces, "Selected Work Area", workArea),
_infoRowWithIcon(
Icons.list_alt, "Selected Activity", activity),
_infoRowWithIcon(Icons.check_circle_outline,
"Completed Work", completedWork),
]),
const SizedBox(height: 16),
_sectionTitle(Icons.edit_calendar, "Planned Work"),
const SizedBox(height: 6),
_customTextField(
controller: plannedTaskController,
hint: "Enter planned work",
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
_sectionTitle(Icons.description_outlined, "Comment"),
const SizedBox(height: 6),
_customTextField(
controller: descriptionController,
hint: "Enter task description",
maxLines: 3,
),
const SizedBox(height: 16),
_sectionTitle(
Icons.category_outlined, "Selected Work Category"),
const SizedBox(height: 6),
Obx(() {
final categoryMap = controller.categoryIdNameMap;
final String selectedName =
controller.selectedCategoryId.value != null
? (categoryMap[controller
.selectedCategoryId.value!] ??
'Select Category')
: 'Select Category';
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: PopupMenuButton<String>(
padding: EdgeInsets.zero,
onSelected: (val) {
controller.selectCategory(val);
onCategoryChanged(val);
},
itemBuilder: (context) => categoryMap.entries
.map(
(entry) => PopupMenuItem<String>(
value: entry.key,
child: Text(entry.value),
),
)
.toList(),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
selectedName,
style: const TextStyle(
fontSize: 14, color: Colors.black87),
),
const Icon(Icons.arrow_drop_down),
],
),
),
);
}),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => Get.back(),
icon: const Icon(Icons.close, size: 18),
label: MyText.bodyMedium("Cancel",
fontWeight: 600),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.grey),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () async {
final plannedValue = int.tryParse(
plannedTaskController.text.trim()) ??
0;
final comment =
descriptionController.text.trim();
final selectedCategoryId =
controller.selectedCategoryId.value;
if (selectedCategoryId == null) {
showAppSnackbar(
title: "error",
message: "Please select a work category!",
type: SnackbarType.error,
);
return;
}
final success = await controller.createTask(
parentTaskId: parentTaskId,
plannedTask: plannedValue,
comment: comment,
workAreaId: workAreaId,
activityId: activityId,
categoryId: selectedCategoryId,
);
if (success) {
Get.back();
Future.delayed(
const Duration(milliseconds: 300), () {
onSubmit();
showAppSnackbar(
title: "Success",
message: "Task created successfully!",
type: SnackbarType.success,
);
});
}
},
icon: const Icon(Icons.check, size: 18),
label: MyText.bodyMedium("Submit",
color: Colors.white, fontWeight: 600),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
),
),
],
),
],
),
),
),
),
);
},
);
},
),
isScrollControlled: true,
);
}
Widget _sectionTitle(IconData icon, String title) {
return Row(
children: [
Icon(icon, color: Colors.grey[700], size: 18),
const SizedBox(width: 8),
MyText.bodyMedium(title, fontWeight: 600),
],
);
}
Widget _customTextField({
required TextEditingController controller,
required String hint,
int maxLines = 1,
TextInputType keyboardType = TextInputType.text,
}) {
return TextField(
controller: controller,
maxLines: maxLines,
keyboardType: keyboardType,
decoration: InputDecoration(
hintText: hint,
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
);
}
Widget _infoCardSection(List<Widget> children) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(children: children),
);
}
Widget _infoRowWithIcon(IconData icon, String title, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: Colors.grey[700], size: 18),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium(title, fontWeight: 600),
const SizedBox(height: 2),
MyText.bodySmall(value, color: Colors.grey[800]),
],
),
),
],
),
);
}

View File

@ -127,8 +127,8 @@ class WorkItem {
final WorkAreaBasic? workArea; final WorkAreaBasic? workArea;
final ActivityMaster? activityMaster; final ActivityMaster? activityMaster;
final WorkCategoryMaster? workCategoryMaster; final WorkCategoryMaster? workCategoryMaster;
final int? plannedWork; final double? plannedWork;
final int? completedWork; final double? completedWork;
final DateTime? taskDate; final DateTime? taskDate;
final String? tenantId; final String? tenantId;
final Tenant? tenant; final Tenant? tenant;
@ -165,8 +165,12 @@ class WorkItem {
? WorkCategoryMaster.fromJson( ? WorkCategoryMaster.fromJson(
json['workCategoryMaster'] as Map<String, dynamic>) json['workCategoryMaster'] as Map<String, dynamic>)
: null, : null,
plannedWork: json['plannedWork'] as int?, plannedWork: json['plannedWork'] != null
completedWork: json['completedWork'] as int?, ? (json['plannedWork'] as num).toDouble()
: null,
completedWork: json['completedWork'] != null
? (json['completedWork'] as num).toDouble()
: null,
taskDate: taskDate:
json['taskDate'] != null ? DateTime.tryParse(json['taskDate']) : null, json['taskDate'] != null ? DateTime.tryParse(json['taskDate']) : null,
tenantId: json['tenantId'] as String?, tenantId: json['tenantId'] as String?,

View File

@ -0,0 +1,31 @@
class WorkCategoryModel {
final String id;
final String name;
final String description;
final bool isSystem;
WorkCategoryModel({
required this.id,
required this.name,
required this.description,
required this.isSystem,
});
factory WorkCategoryModel.fromJson(Map<String, dynamic> json) {
return WorkCategoryModel(
id: json['id'] ?? '',
name: json['name'] ?? '',
description: json['description'] ?? '',
isSystem: json['isSystem'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'isSystem': isSystem,
};
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:marco/model/dailyTaskPlaning/comment_task_bottom_sheet.dart';
import 'package:marco/model/dailyTaskPlaning/report_task_bottom_sheet.dart';
import 'package:marco/model/dailyTaskPlaning/report_action_bottom_sheet.dart';
class TaskActionButtons {
static Widget reportButton({
required BuildContext context,
required dynamic task,
required int completed,
required VoidCallback refreshCallback,
}) {
return OutlinedButton.icon(
icon: const Icon(Icons.report, size: 18, color: Colors.blueAccent),
label: const Text('Report', style: TextStyle(color: Colors.blueAccent)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.blueAccent),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
textStyle: const TextStyle(fontSize: 14),
),
onPressed: () {
final activityName =
task.workItem?.activityMaster?.activityName ?? 'N/A';
final assigned = '${(task.plannedTask - completed)}';
final assignedBy =
"${task.assignedBy.firstName} ${task.assignedBy.lastName ?? ''}";
final assignedOn = DateFormat('dd-MM-yyyy').format(task.assignmentDate);
final taskId = task.id;
final location = [
task.workItem?.workArea?.floor?.building?.name,
task.workItem?.workArea?.floor?.floorName,
task.workItem?.workArea?.areaName,
].where((e) => e != null && e.isNotEmpty).join(' > ');
final teamMembers = task.teamMembers.map((e) => e.firstName).toList();
final pendingWork = (task.workItem?.plannedWork ?? 0) -
(task.workItem?.completedWork ?? 0);
final taskData = {
'activity': activityName,
'assigned': assigned,
'taskId': taskId,
'assignedBy': assignedBy,
'completed': completed,
'assignedOn': assignedOn,
'location': location,
'teamSize': task.teamMembers.length,
'teamMembers': teamMembers,
'pendingWork': pendingWork,
};
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => Padding(
padding: MediaQuery.of(ctx).viewInsets,
child: ReportTaskBottomSheet(
taskData: taskData,
onReportSuccess: refreshCallback,
),
),
);
},
);
}
static Widget commentButton({
required BuildContext context,
required dynamic task,
required VoidCallback refreshCallback,
}) {
return OutlinedButton.icon(
icon: const Icon(Icons.comment, size: 18, color: Colors.blueAccent),
label: const Text('Comment', style: TextStyle(color: Colors.blueAccent)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.blueAccent),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
textStyle: const TextStyle(fontSize: 14),
),
onPressed: () {
final taskData = _prepareTaskData(task: task, completed: task.completedTask.toInt());
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => CommentTaskBottomSheet(
taskData: taskData,
onCommentSuccess: () {
refreshCallback();
Navigator.of(context).pop();
},
),
);
},
);
}
static Widget reportActionButton({
required BuildContext context,
required dynamic task,
required int completed,
required VoidCallback refreshCallback,
required String parentTaskID,
required String activityId,
required String workAreaId,
}) {
return OutlinedButton.icon(
icon: const Icon(Icons.report, size: 18, color: Colors.amber),
label: const Text('Take Report Action',
style: TextStyle(color: Colors.amber)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.amber),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
textStyle: const TextStyle(fontSize: 14),
),
onPressed: () {
final taskData = _prepareTaskData(task: task, completed: completed);
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => Padding(
padding: MediaQuery.of(ctx).viewInsets,
child: ReportActionBottomSheet(
taskData: taskData,
taskDataId: parentTaskID,
workAreaId: workAreaId,
activityId: activityId,
onReportSuccess: refreshCallback,
),
),
);
},
);
}
static Map<String, dynamic> _prepareTaskData({
required dynamic task,
required int completed,
}) {
final activityName = task.workItem?.activityMaster?.activityName ?? 'N/A';
final assigned = '${(task.plannedTask - completed)}';
final assignedBy =
"${task.assignedBy.firstName} ${task.assignedBy.lastName ?? ''}";
final assignedOn = DateFormat('yyyy-MM-dd').format(task.assignmentDate);
final taskId = task.id;
final location = [
task.workItem?.workArea?.floor?.building?.name,
task.workItem?.workArea?.floor?.floorName,
task.workItem?.workArea?.areaName,
].where((e) => e != null && e.isNotEmpty).join(' > ');
final teamMembers = task.teamMembers
.map((e) => '${e.firstName} ${e.lastName ?? ''}')
.toList();
final pendingWork =
(task.workItem?.plannedWork ?? 0) - (task.workItem?.completedWork ?? 0);
final taskComments = task.comments.map((comment) {
final isoDate = comment.timestamp.toIso8601String();
final commenterName = comment.commentedBy.firstName.isNotEmpty
? "${comment.commentedBy.firstName} ${comment.commentedBy.lastName ?? ''}"
.trim()
: "Unknown";
return {
'text': comment.comment,
'date': isoDate,
'commentedBy': commenterName,
'preSignedUrls': comment.preSignedUrls,
};
}).toList();
final taskLevelPreSignedUrls = task.reportedPreSignedUrls;
return {
'activity': activityName,
'assigned': assigned,
'taskId': taskId,
'assignedBy': assignedBy,
'completed': completed,
'plannedWork': task.plannedTask.toString(),
'completedWork': completed.toString(),
'assignedOn': assignedOn,
'location': location,
'teamSize': task.teamMembers.length,
'teamMembers': teamMembers,
'pendingWork': pendingWork,
'taskComments': taskComments,
'reportedPreSignedUrls': taskLevelPreSignedUrls,
};
}
}

View File

@ -0,0 +1,53 @@
class WorkStatusResponseModel {
final bool success;
final String message;
final List<WorkStatus> data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
WorkStatusResponseModel({
required this.success,
required this.message,
required this.data,
required this.errors,
required this.statusCode,
required this.timestamp,
});
factory WorkStatusResponseModel.fromJson(Map<String, dynamic> json) {
return WorkStatusResponseModel(
success: json['success'],
message: json['message'],
data: List<WorkStatus>.from(
json['data'].map((item) => WorkStatus.fromJson(item)),
),
errors: json['errors'],
statusCode: json['statusCode'],
timestamp: DateTime.parse(json['timestamp']),
);
}
}
class WorkStatus {
final String id;
final String name;
final String description;
final bool isSystem;
WorkStatus({
required this.id,
required this.name,
required this.description,
required this.isSystem,
});
factory WorkStatus.fromJson(Map<String, dynamic> json) {
return WorkStatus(
id: json['id'],
name: json['name'],
description: json['description'],
isSystem: json['isSystem'],
);
}
}

View File

@ -4,9 +4,10 @@ class TaskModel {
final String id; final String id;
final WorkItem? workItem; final WorkItem? workItem;
final String workItemId; final String workItemId;
final int plannedTask; final double plannedTask;
final int completedTask; final double completedTask;
final AssignedBy assignedBy; final AssignedBy assignedBy;
final AssignedBy? approvedBy;
final List<TeamMember> teamMembers; final List<TeamMember> teamMembers;
final List<Comment> comments; final List<Comment> comments;
final List<String> reportedPreSignedUrls; final List<String> reportedPreSignedUrls;
@ -20,6 +21,7 @@ class TaskModel {
required this.plannedTask, required this.plannedTask,
required this.completedTask, required this.completedTask,
required this.assignedBy, required this.assignedBy,
this.approvedBy,
required this.teamMembers, required this.teamMembers,
required this.comments, required this.comments,
required this.reportedPreSignedUrls, required this.reportedPreSignedUrls,
@ -35,9 +37,12 @@ class TaskModel {
workItem: workItem:
json['workItem'] != null ? WorkItem.fromJson(json['workItem']) : null, json['workItem'] != null ? WorkItem.fromJson(json['workItem']) : null,
workItemId: json['workItemId'], workItemId: json['workItemId'],
plannedTask: json['plannedTask'], plannedTask: (json['plannedTask'] as num).toDouble(),
completedTask: json['completedTask'], completedTask: (json['completedTask'] as num).toDouble(),
assignedBy: AssignedBy.fromJson(json['assignedBy']), assignedBy: AssignedBy.fromJson(json['assignedBy']),
approvedBy: json['approvedBy'] != null
? AssignedBy.fromJson(json['approvedBy'])
: null,
teamMembers: (json['teamMembers'] as List) teamMembers: (json['teamMembers'] as List)
.map((e) => TeamMember.fromJson(e)) .map((e) => TeamMember.fromJson(e))
.toList(), .toList(),
@ -55,8 +60,8 @@ class WorkItem {
final String? id; final String? id;
final ActivityMaster? activityMaster; final ActivityMaster? activityMaster;
final WorkArea? workArea; final WorkArea? workArea;
final int? plannedWork; final double? plannedWork;
final int? completedWork; final double? completedWork;
final List<String> preSignedUrls; final List<String> preSignedUrls;
WorkItem({ WorkItem({
@ -76,8 +81,8 @@ class WorkItem {
: null, : null,
workArea: workArea:
json['workArea'] != null ? WorkArea.fromJson(json['workArea']) : null, json['workArea'] != null ? WorkArea.fromJson(json['workArea']) : null,
plannedWork: json['plannedWork'], plannedWork: (json['plannedWork'] as num?)?.toDouble(),
completedWork: json['completedWork'], completedWork: (json['completedWork'] as num?)?.toDouble(),
preSignedUrls: (json['preSignedUrls'] as List<dynamic>?) preSignedUrls: (json['preSignedUrls'] as List<dynamic>?)
?.map((e) => e.toString()) ?.map((e) => e.toString())
.toList() ?? .toList() ??
@ -87,23 +92,36 @@ class WorkItem {
} }
class ActivityMaster { class ActivityMaster {
final String? id; // Added
final String activityName; final String activityName;
ActivityMaster({required this.activityName}); ActivityMaster({
this.id,
required this.activityName,
});
factory ActivityMaster.fromJson(Map<String, dynamic> json) { factory ActivityMaster.fromJson(Map<String, dynamic> json) {
return ActivityMaster(activityName: json['activityName'] ?? ''); return ActivityMaster(
id: json['id']?.toString(),
activityName: json['activityName'] ?? '',
);
} }
} }
class WorkArea { class WorkArea {
final String? id; // Added
final String areaName; final String areaName;
final Floor? floor; final Floor? floor;
WorkArea({required this.areaName, this.floor}); WorkArea({
this.id,
required this.areaName,
this.floor,
});
factory WorkArea.fromJson(Map<String, dynamic> json) { factory WorkArea.fromJson(Map<String, dynamic> json) {
return WorkArea( return WorkArea(
id: json['id']?.toString(),
areaName: json['areaName'] ?? '', areaName: json['areaName'] ?? '',
floor: json['floor'] != null ? Floor.fromJson(json['floor']) : null, floor: json['floor'] != null ? Floor.fromJson(json['floor']) : null,
); );

View File

@ -0,0 +1,19 @@
class AttendanceOverview {
final String role;
final String date;
final int present;
AttendanceOverview({
required this.role,
required this.date,
required this.present,
});
factory AttendanceOverview.fromJson(Map<String, dynamic> json) {
return AttendanceOverview(
role: json['role'] ?? '',
date: json['date'] ?? '',
present: json['present'] ?? 0,
);
}
}

View File

@ -1,361 +1,103 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/dashboard/add_employee_controller.dart'; import 'package:marco/controller/dashboard/add_employee_controller.dart';
import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/controller/dashboard/employees_screen_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/controller/dashboard/employees_screen_controller.dart';
class AddEmployeeBottomSheet extends StatefulWidget { class AddEmployeeBottomSheet extends StatefulWidget {
@override @override
_AddEmployeeBottomSheetState createState() => _AddEmployeeBottomSheetState(); State<AddEmployeeBottomSheet> createState() => _AddEmployeeBottomSheetState();
} }
class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> with UIMixin {
with UIMixin { final AddEmployeeController _controller = Get.put(AddEmployeeController());
final AddEmployeeController controller = Get.put(AddEmployeeController());
late TextEditingController genderController;
late TextEditingController roleController;
@override @override
Widget build(BuildContext context) { void initState() {
final theme = Theme.of(context); super.initState();
genderController = TextEditingController();
roleController = TextEditingController();
}
return GetBuilder<AddEmployeeController>( RelativeRect _popupMenuPosition(BuildContext context) {
init: controller, final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
builder: (_) { return RelativeRect.fromLTRB(100, 300, overlay.size.width - 100, 0);
return SingleChildScrollView( }
padding: MediaQuery.of(context).viewInsets,
child: Container(
padding:
const EdgeInsets.only(top: 12, left: 24, right: 24, bottom: 24),
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 10,
spreadRadius: 1,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Drag handle bar
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MyText.titleMedium(
"Add Employee",
fontWeight: 600,
fontSize: 18,
),
],
),
MySpacing.height(24),
Form( void _showGenderPopup(BuildContext context) async {
key: controller.basicValidator.formKey, final selected = await showMenu<Gender>(
child: Column( context: context,
crossAxisAlignment: CrossAxisAlignment.start, position: _popupMenuPosition(context),
children: [ items: Gender.values.map((gender) {
_buildLabel("First Name"), return PopupMenuItem<Gender>(
MySpacing.height(8), value: gender,
_buildTextField( child: Text(gender.name.capitalizeFirst!),
hint: "eg: John",
controller: controller.basicValidator
.getController('first_name')!,
validator: controller.basicValidator
.getValidation('first_name'),
keyboardType: TextInputType.name,
),
MySpacing.height(24),
_buildLabel("Last Name"),
MySpacing.height(8),
_buildTextField(
hint: "eg: Doe",
controller: controller.basicValidator
.getController('last_name')!,
validator: controller.basicValidator
.getValidation('last_name'),
keyboardType: TextInputType.name,
),
MySpacing.height(24),
_buildLabel("Phone Number"),
MySpacing.height(8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 14),
decoration: BoxDecoration(
border:
Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: PopupMenuButton<Map<String, String>>(
onSelected: (country) {
setState(() {
controller.selectedCountryCode =
country['code']!;
});
},
itemBuilder: (context) => [
PopupMenuItem(
enabled: false,
padding: EdgeInsets.zero,
child: Container(
padding: EdgeInsets.zero,
height: 200,
width: 100,
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surface,
borderRadius:
BorderRadius.circular(8),
),
child: Scrollbar(
child: ListView(
padding: EdgeInsets.zero,
children: controller.countries
.map((country) {
return ListTile(
title: Text(
"${country['name']} (${country['code']})",
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface,
),
),
onTap: () => Navigator.pop(
context, country),
hoverColor: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.1),
contentPadding:
const EdgeInsets.symmetric(
horizontal: 12),
); );
}).toList(), }).toList(),
), );
),
), if (selected != null) {
), _controller.onGenderSelected(selected);
], _controller.update();
child: Row( }
mainAxisSize: MainAxisSize.min,
children: [
Text(controller.selectedCountryCode),
const Icon(Icons.arrow_drop_down),
],
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
children: [
TextFormField(
controller: controller.basicValidator
.getController('phone_number'),
validator: (value) {
if (value == null ||
value.trim().isEmpty) {
return "Phone number is required";
} }
final digitsOnly = value.trim(); void _showRolePopup(BuildContext context) async {
final minLength = final selected = await showMenu<String>(
controller.minDigitsPerCountry[ context: context,
controller position: _popupMenuPosition(context),
.selectedCountryCode] ?? items: _controller.roles.map((role) {
7; return PopupMenuItem<String>(
final maxLength =
controller.maxDigitsPerCountry[
controller
.selectedCountryCode] ??
15;
if (!RegExp(r'^[0-9]+$')
.hasMatch(digitsOnly)) {
return "Phone number must contain digits only";
}
if (digitsOnly.length < minLength ||
digitsOnly.length > maxLength) {
return "Number Must be between $minLength and $maxLength";
}
return null;
},
keyboardType: TextInputType.phone,
decoration:
_inputDecoration("eg: 9876543210")
.copyWith(
suffixIcon: IconButton(
icon: Icon(Icons.contacts),
tooltip: "Pick from contacts",
onPressed: () async {
await controller
.pickContact(context);
},
),
),
),
],
),
),
],
),
],
),
MySpacing.height(24),
_buildLabel("Select Gender"),
MySpacing.height(8),
DropdownButtonFormField<Gender>(
value: controller.selectedGender,
dropdownColor: contentTheme.background,
isDense: true,
menuMaxHeight: 200,
decoration: _inputDecoration("Select Gender"),
icon: const Icon(Icons.expand_more, size: 20),
items: Gender.values
.map(
(gender) => DropdownMenuItem<Gender>(
value: gender,
child: MyText.labelMedium(
gender.name.capitalizeFirst!),
),
)
.toList(),
onChanged: controller.onGenderSelected,
),
MySpacing.height(24),
_buildLabel("Select Role"),
MySpacing.height(8),
DropdownButtonFormField<String>(
value: controller.selectedRoleId,
dropdownColor: contentTheme.background,
isDense: true,
decoration: _inputDecoration("Select Role"),
icon: const Icon(Icons.expand_more, size: 20),
items: controller.roles
.map(
(role) => DropdownMenuItem<String>(
value: role['id'], value: role['id'],
child: Text(role['name']), child: Text(role['name']),
), );
) }).toList(),
.toList(), );
onChanged: controller.onRoleSelected,
),
MySpacing.height(24),
// Buttons row if (selected != null) {
Row( _controller.onRoleSelected(selected);
mainAxisAlignment: MainAxisAlignment.end, _controller.update();
}
}
Widget _sectionLabel(String title) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyButton.text( MyText.labelLarge(title, fontWeight: 600),
onPressed: () => Navigator.pop(context), MySpacing.height(4),
padding: MySpacing.xy(20, 16), Divider(thickness: 1, color: Colors.grey.shade200),
child: MyText.bodySmall("Cancel"),
),
MySpacing.width(12),
MyButton(
onPressed: () async {
if (controller.basicValidator.validateForm()) {
final success =
await controller.createEmployees();
if (success) {
// Call refresh logic here via callback or GetX
final employeeController =
Get.find<EmployeesScreenController>();
final projectId =
employeeController.selectedProjectId;
if (projectId == null) {
await employeeController
.fetchAllEmployees();
} else {
await employeeController
.fetchEmployeesByProject(projectId);
}
employeeController
.update(['employee_screen_controller']);
controller.basicValidator
.getController("first_name")
?.clear();
controller.basicValidator
.getController("last_name")
?.clear();
controller.basicValidator
.getController("phone_number")
?.clear();
controller.selectedGender = null;
controller.selectedRoleId = null;
controller.update();
}
}
},
elevation: 0,
padding: MySpacing.xy(20, 16),
backgroundColor: Colors.blueAccent,
borderRadiusAll: AppStyle.buttonRadius.medium,
child: MyText.bodySmall("Save",
color: contentTheme.onPrimary),
),
], ],
),
],
),
),
],
),
),
);
},
); );
} }
Widget _buildLabel(String text) => MyText.labelMedium(text); Widget _inputWithIcon({
required String label,
Widget _buildTextField({ required String hint,
required IconData icon,
required TextEditingController controller, required TextEditingController controller,
required String? Function(String?)? validator, required String? Function(String?)? validator,
required String hint,
TextInputType keyboardType = TextInputType.text,
}) { }) {
return TextFormField( return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
MySpacing.height(8),
TextFormField(
controller: controller, controller: controller,
validator: validator, validator: validator,
keyboardType: keyboardType, decoration: _inputDecoration(hint).copyWith(
decoration: _inputDecoration(hint), prefixIcon: Icon(icon, size: 20),
),
),
],
); );
} }
@ -363,12 +105,258 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
return InputDecoration( return InputDecoration(
hintText: hint, hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true), hintStyle: MyTextStyle.bodySmall(xMuted: true),
border: outlineInputBorder, filled: true,
enabledBorder: outlineInputBorder, fillColor: Colors.grey.shade100,
focusedBorder: focusedInputBorder, border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
),
contentPadding: MySpacing.all(16), contentPadding: MySpacing.all(16),
isCollapsed: true, );
floatingLabelBehavior: FloatingLabelBehavior.never, }
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return GetBuilder<AddEmployeeController>(
init: _controller,
builder: (_) {
return SingleChildScrollView(
padding: MediaQuery.of(context).viewInsets,
child: Container(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, -2))],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag Handle
Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
),
MySpacing.height(12),
Text("Add Employee", style: MyTextStyle.titleLarge(fontWeight: 700)),
MySpacing.height(24),
Form(
key: _controller.basicValidator.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel("Personal Info"),
MySpacing.height(16),
_inputWithIcon(
label: "First Name",
hint: "e.g., John",
icon: Icons.person,
controller: _controller.basicValidator.getController('first_name')!,
validator: _controller.basicValidator.getValidation('first_name'),
),
MySpacing.height(16),
_inputWithIcon(
label: "Last Name",
hint: "e.g., Doe",
icon: Icons.person_outline,
controller: _controller.basicValidator.getController('last_name')!,
validator: _controller.basicValidator.getValidation('last_name'),
),
MySpacing.height(16),
_sectionLabel("Contact Details"),
MySpacing.height(16),
MyText.labelMedium("Phone Number"),
MySpacing.height(8),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
color: Colors.grey.shade100,
),
child: PopupMenuButton<Map<String, String>>(
onSelected: (country) {
_controller.selectedCountryCode = country['code']!;
_controller.update();
},
itemBuilder: (context) => [
PopupMenuItem(
enabled: false,
padding: EdgeInsets.zero,
child: SizedBox(
height: 200,
width: 100,
child: ListView(
children: _controller.countries.map((country) {
return ListTile(
dense: true,
title: Text("${country['name']} (${country['code']})"),
onTap: () => Navigator.pop(context, country),
);
}).toList(),
),
),
),
],
child: Row(
children: [
Text(_controller.selectedCountryCode),
const Icon(Icons.arrow_drop_down),
],
),
),
),
MySpacing.width(12),
Expanded(
child: TextFormField(
controller: _controller.basicValidator.getController('phone_number'),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "Phone number is required";
}
final digitsOnly = value.trim();
final minLength = _controller.minDigitsPerCountry[_controller.selectedCountryCode] ?? 7;
final maxLength = _controller.maxDigitsPerCountry[_controller.selectedCountryCode] ?? 15;
if (!RegExp(r'^[0-9]+$').hasMatch(digitsOnly)) {
return "Only digits allowed";
}
if (digitsOnly.length < minLength || digitsOnly.length > maxLength) {
return "Between $minLength$maxLength digits";
}
return null;
},
keyboardType: TextInputType.phone,
decoration: _inputDecoration("e.g., 9876543210").copyWith(
suffixIcon: IconButton(
icon: const Icon(Icons.contacts),
onPressed: () => _controller.pickContact(context),
),
),
),
),
],
),
MySpacing.height(24),
_sectionLabel("Other Details"),
MySpacing.height(16),
MyText.labelMedium("Gender"),
MySpacing.height(8),
GestureDetector(
onTap: () => _showGenderPopup(context),
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: TextEditingController(
text: _controller.selectedGender?.name.capitalizeFirst,
),
decoration: _inputDecoration("Select Gender").copyWith(
suffixIcon: const Icon(Icons.expand_more),
),
),
),
),
MySpacing.height(16),
MyText.labelMedium("Role"),
MySpacing.height(8),
GestureDetector(
onTap: () => _showRolePopup(context),
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: TextEditingController(
text: _controller.roles.firstWhereOrNull(
(role) => role['id'] == _controller.selectedRoleId,
)?['name'] ?? "",
),
decoration: _inputDecoration("Select Role").copyWith(
suffixIcon: const Icon(Icons.expand_more),
),
),
),
),
MySpacing.height(16),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close, size: 18),
label: MyText.bodyMedium("Cancel", fontWeight: 600),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.grey),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () async {
if (_controller.basicValidator.validateForm()) {
final success = await _controller.createEmployees();
if (success) {
final employeeController = Get.find<EmployeesScreenController>();
final projectId = employeeController.selectedProjectId;
if (projectId == null) {
await employeeController.fetchAllEmployees();
} else {
await employeeController.fetchEmployeesByProject(projectId);
}
employeeController.update(['employee_screen_controller']);
_controller.basicValidator.getController("first_name")?.clear();
_controller.basicValidator.getController("last_name")?.clear();
_controller.basicValidator.getController("phone_number")?.clear();
_controller.selectedGender = null;
_controller.selectedRoleId = null;
_controller.update();
Navigator.pop(context);
}
}
},
icon: const Icon(Icons.check, size: 18),
label: MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
],
),
],
),
),
],
),
),
),
);
},
); );
} }
} }

View File

@ -0,0 +1,51 @@
class GlobalProjectModel {
final String id;
final String name;
final String projectAddress;
final String contactPerson;
final DateTime startDate;
final DateTime endDate;
final int teamSize;
final String projectStatusId;
final String? tenantId;
GlobalProjectModel({
required this.id,
required this.name,
required this.projectAddress,
required this.contactPerson,
required this.startDate,
required this.endDate,
required this.teamSize,
required this.projectStatusId,
this.tenantId,
});
factory GlobalProjectModel.fromJson(Map<String, dynamic> json) {
return GlobalProjectModel(
id: json['id'] ?? '',
name: json['name'] ?? '',
projectAddress: json['projectAddress'] ?? '',
contactPerson: json['contactPerson'] ?? '',
startDate: DateTime.parse(json['startDate']),
endDate: DateTime.parse(json['endDate']),
teamSize: json['teamSize'] ?? 0, // SAFER
projectStatusId: json['projectStatusId'] ?? '',
tenantId: json['tenantId'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'projectAddress': projectAddress,
'contactPerson': contactPerson,
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
'teamSize': teamSize,
'projectStatusId': projectStatusId,
'tenantId': tenantId,
};
}
}

View File

@ -6,8 +6,8 @@ class ProjectModel {
final DateTime startDate; final DateTime startDate;
final DateTime endDate; final DateTime endDate;
final int teamSize; final int teamSize;
final int completedWork; final double completedWork;
final int plannedWork; final double plannedWork;
final String projectStatusId; final String projectStatusId;
final String? tenantId; final String? tenantId;
@ -35,8 +35,12 @@ class ProjectModel {
startDate: DateTime.parse(json['startDate']), startDate: DateTime.parse(json['startDate']),
endDate: DateTime.parse(json['endDate']), endDate: DateTime.parse(json['endDate']),
teamSize: json['teamSize'], teamSize: json['teamSize'],
completedWork: json['completedWork'], completedWork: json['completedWork'] != null
plannedWork: json['plannedWork'], ? (json['completedWork'] as num).toDouble()
: 0.0,
plannedWork: json['plannedWork'] != null
? (json['plannedWork'] as num).toDouble()
: 0.0,
projectStatusId: json['projectStatusId'], projectStatusId: json['projectStatusId'],
tenantId: json['tenantId'], tenantId: json['tenantId'],
); );

View File

@ -18,6 +18,7 @@ import 'package:marco/model/attendance/attendence_action_button.dart';
import 'package:marco/model/attendance/regualrize_action_button.dart'; import 'package:marco/model/attendance/regualrize_action_button.dart';
import 'package:marco/model/attendance/attendence_filter_sheet.dart'; import 'package:marco/model/attendance/attendence_filter_sheet.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
class AttendanceScreen extends StatefulWidget { class AttendanceScreen extends StatefulWidget {
AttendanceScreen({super.key}); AttendanceScreen({super.key});
@ -304,10 +305,7 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
), ),
), ),
if (isLoading) if (isLoading)
const SizedBox( SkeletonLoaders.employeeListSkeletonLoader()
height: 120,
child: Center(child: CircularProgressIndicator()),
)
else if (employees.isEmpty) else if (employees.isEmpty)
SizedBox( SizedBox(
height: 120, height: 120,
@ -503,10 +501,7 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
), ),
), ),
if (attendanceController.isLoadingAttendanceLogs.value) if (attendanceController.isLoadingAttendanceLogs.value)
const SizedBox( SkeletonLoaders.employeeListSkeletonLoader()
height: 120,
child: Center(child: CircularProgressIndicator()),
)
else if (logs.isEmpty) else if (logs.isEmpty)
SizedBox( SizedBox(
height: 120, height: 120,
@ -691,10 +686,7 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
Obx(() { Obx(() {
final employees = attendanceController.regularizationLogs; final employees = attendanceController.regularizationLogs;
if (attendanceController.isLoadingRegularizationLogs.value) { if (attendanceController.isLoadingRegularizationLogs.value) {
return SizedBox( return SkeletonLoaders.employeeListSkeletonLoader();
height: 120,
child: const Center(child: CircularProgressIndicator()),
);
} }
if (employees.isEmpty) { if (employees.isEmpty) {

View File

@ -0,0 +1,295 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
class AttendanceDashboardChart extends StatelessWidget {
final DashboardController controller = Get.find<DashboardController>();
AttendanceDashboardChart({super.key});
List<Map<String, dynamic>> get filteredData {
final now = DateTime.now();
final daysBack = controller.rangeDays;
return controller.roleWiseData.where((entry) {
final date = DateTime.parse(entry['date']);
return date.isAfter(now.subtract(Duration(days: daysBack))) &&
!date.isAfter(now);
}).toList();
}
List<DateTime> get filteredDateTimes {
final uniqueDates = filteredData
.map((e) => DateTime.parse(e['date'] as String))
.toSet()
.toList()
..sort();
return uniqueDates;
}
List<String> get filteredDates =>
filteredDateTimes.map((d) => DateFormat('d MMMM').format(d)).toList();
List<String> get filteredRoles =>
filteredData.map((e) => e['role'] as String).toSet().toList();
final Map<String, Color> _roleColorMap = {};
final List<Color> flatColors = [
const Color(0xFFE57373),
const Color(0xFF64B5F6),
const Color(0xFF81C784),
const Color(0xFFFFB74D),
const Color(0xFFBA68C8),
const Color(0xFFFF8A65),
const Color(0xFF4DB6AC),
const Color(0xFFA1887F),
const Color(0xFFDCE775),
const Color(0xFF9575CD),
];
Color _getRoleColor(String role) {
if (_roleColorMap.containsKey(role)) return _roleColorMap[role]!;
final index = _roleColorMap.length % flatColors.length;
final color = flatColors[index];
_roleColorMap[role] = color;
return color;
}
@override
Widget build(BuildContext context) {
return Obx(() {
final isChartView = controller.isChartView.value;
final selectedRange = controller.selectedRange.value;
final isLoading = controller.isLoading.value;
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xfff0f4f8), Color(0xffe2ebf0)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Card(
color: Colors.white,
elevation: 6,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
shadowColor: Colors.black12,
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(selectedRange, isChartView),
const SizedBox(height: 12),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: isLoading
? SkeletonLoaders.buildLoadingSkeleton()
: isChartView
? _buildChart()
: _buildTable(),
),
],
),
),
),
);
});
}
Widget _buildHeader(String selectedRange, bool isChartView) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium('Attendance Overview', fontWeight: 600),
MyText.bodySmall(
'Role-wise present count',
color: Colors.grey,
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton<String>(
padding: EdgeInsets.zero,
tooltip: 'Select Range',
onSelected: (value) => controller.selectedRange.value = value,
itemBuilder: (context) => const [
PopupMenuItem(value: '7D', child: Text('Last 7 Days')),
PopupMenuItem(value: '15D', child: Text('Last 15 Days')),
PopupMenuItem(value: '30D', child: Text('Last 30 Days')),
],
child: Row(
children: [
const Icon(Icons.calendar_today_outlined, size: 18),
const SizedBox(width: 4),
MyText.labelSmall(selectedRange),
],
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
Icons.bar_chart_rounded,
size: 20,
color: isChartView ? Colors.blueAccent : Colors.grey,
),
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
onPressed: () => controller.isChartView.value = true,
tooltip: 'Chart View',
),
IconButton(
icon: Icon(
Icons.table_chart,
size: 20,
color: !isChartView ? Colors.blueAccent : Colors.grey,
),
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
onPressed: () => controller.isChartView.value = false,
tooltip: 'Table View',
),
],
),
),
],
),
);
}
Widget _buildChart() {
final formattedDateMap = {
for (var e in filteredData)
'${e['role']}_${DateFormat('d MMMM').format(DateTime.parse(e['date']))}':
e['present']
};
return SizedBox(
height: 360,
child: SfCartesianChart(
tooltipBehavior: TooltipBehavior(
enable: true,
shared: true,
activationMode: ActivationMode.singleTap,
tooltipPosition: TooltipPosition.pointer,
),
legend: const Legend(
isVisible: true,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
),
primaryXAxis: CategoryAxis(
labelRotation: 45,
majorGridLines: const MajorGridLines(width: 0),
),
primaryYAxis: NumericAxis(
minimum: 0,
interval: 1,
majorGridLines: const MajorGridLines(width: 0),
),
series: filteredRoles.map((role) {
final data = filteredDates.map((formattedDate) {
final key = '${role}_$formattedDate';
return {
'date': formattedDate,
'present': formattedDateMap[key] ?? 0
};
}).toList();
return StackedColumnSeries<Map<String, dynamic>, String>(
dataSource: data,
xValueMapper: (d, _) => d['date'],
yValueMapper: (d, _) => d['present'],
name: role,
legendIconType: LegendIconType.circle,
dataLabelSettings: const DataLabelSettings(isVisible: true),
color: _getRoleColor(role),
);
}).toList(),
),
);
}
Widget _buildTable() {
final formattedDateMap = {
for (var e in filteredData)
'${e['role']}_${DateFormat('d MMMM').format(DateTime.parse(e['date']))}':
e['present']
};
return Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columnSpacing: 28,
headingRowHeight: 42,
headingRowColor:
WidgetStateProperty.all(Colors.blueAccent.withOpacity(0.1)),
headingTextStyle: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.black87),
columns: [
DataColumn(label: MyText.labelSmall('Role', fontWeight: 600)),
...filteredDates.map((date) => DataColumn(
label: MyText.labelSmall(date, fontWeight: 600),
)),
],
rows: filteredRoles.map((role) {
return DataRow(
cells: [
DataCell(Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: _rolePill(role),
)),
...filteredDates.map((date) {
final key = '${role}_$date';
return DataCell(Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: MyText.labelSmall('${formattedDateMap[key] ?? 0}'),
));
}),
],
);
}).toList(),
),
),
);
}
Widget _rolePill(String role) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: _getRoleColor(role).withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
),
child: MyText.labelSmall(role, fontWeight: 500),
);
}
}

View File

@ -1,19 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/utils/my_shadow.dart'; import 'package:marco/helpers/utils/my_shadow.dart';
import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_card.dart'; import 'package:marco/helpers/widgets/my_card.dart';
import 'package:marco/helpers/widgets/my_container.dart'; import 'package:marco/helpers/widgets/my_container.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/view/dashboard/dashboard_chart.dart';
import 'package:marco/view/layouts/layout.dart'; import 'package:marco/view/layouts/layout.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/controller/project_controller.dart';
class DashboardScreen extends StatefulWidget { class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key}); const DashboardScreen({super.key});
static const String dashboardRoute = "/dashboard"; static const String dashboardRoute = "/dashboard";
static const String employeesRoute = "/dashboard/employees"; static const String employeesRoute = "/dashboard/employees";
static const String projectsRoute = "/dashboard"; static const String projectsRoute = "/dashboard";
@ -28,6 +31,8 @@ class DashboardScreen extends StatefulWidget {
} }
class _DashboardScreenState extends State<DashboardScreen> with UIMixin { class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
final DashboardController dashboardController =
Get.put(DashboardController());
bool hasMpin = true; bool hasMpin = true;
@override @override
@ -53,6 +58,9 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
children: [ children: [
MySpacing.height(12), MySpacing.height(12),
_buildDashboardStats(), _buildDashboardStats(),
MySpacing.height(24),
AttendanceDashboardChart(),
MySpacing.height(300), MySpacing.height(300),
if (!hasMpin) ...[ if (!hasMpin) ...[
MyCard( MyCard(
@ -63,7 +71,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(Icons.warning_amber_rounded, const Icon(Icons.warning_amber_rounded,
color: Colors.redAccent, size: 28), color: Colors.redAccent, size: 28),
MySpacing.width(12), MySpacing.width(12),
Expanded( Expanded(
@ -93,7 +101,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.lock_outline, const Icon(Icons.lock_outline,
size: 18, color: Colors.white), size: 18, color: Colors.white),
MySpacing.width(8), MySpacing.width(8),
MyText.bodyMedium( MyText.bodyMedium(
@ -133,8 +141,21 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
return GetBuilder<ProjectController>( return GetBuilder<ProjectController>(
id: 'dashboard_controller', id: 'dashboard_controller',
builder: (controller) { builder: (controller) {
final bool isLoading = controller.isLoading.value;
final bool isProjectSelected = controller.selectedProject != null; final bool isProjectSelected = controller.selectedProject != null;
if (isLoading) {
return Wrap(
spacing: 10,
runSpacing: 10,
children: List.generate(
4,
(index) =>
_buildStatCardSkeleton(MediaQuery.of(context).size.width / 3),
),
);
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -184,6 +205,33 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
); );
} }
Widget _buildStatCardSkeleton(double width) {
return MyCard.bordered(
width: width,
height: 100,
paddingAll: 5,
borderRadiusAll: 10,
border: Border.all(color: Colors.grey.withOpacity(0.15)),
shadow: MyShadow(elevation: 1.5, position: MyShadowPosition.bottom),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MyContainer.rounded(
paddingAll: 12,
color: Colors.grey.shade300,
child: const SizedBox(width: 18, height: 18),
),
MySpacing.height(8),
Container(
height: 12,
width: 60,
color: Colors.grey.shade300,
),
],
),
);
}
Widget _buildStatCard(_StatItem statItem, double width, bool isEnabled) { Widget _buildStatCard(_StatItem statItem, double width, bool isEnabled) {
return Opacity( return Opacity(
opacity: isEnabled ? 1.0 : 0.4, opacity: isEnabled ? 1.0 : 0.4,

View File

@ -14,7 +14,7 @@ import 'package:marco/controller/dashboard/employees_screen_controller.dart';
import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/model/employees/employee_detail_bottom_sheet.dart'; import 'package:marco/model/employees/employee_detail_bottom_sheet.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
class EmployeesScreen extends StatefulWidget { class EmployeesScreen extends StatefulWidget {
const EmployeesScreen({super.key}); const EmployeesScreen({super.key});
@ -292,7 +292,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
final isLoading = employeeScreenController.isLoading.value; final isLoading = employeeScreenController.isLoading.value;
final employees = employeeScreenController.employees; final employees = employeeScreenController.employees;
if (isLoading) { if (isLoading) {
return const Center(child: CircularProgressIndicator()); return SkeletonLoaders.employeeListSkeletonLoader();
} }
if (employees.isEmpty) { if (employees.isEmpty) {
return Padding( return Padding(

View File

@ -46,6 +46,13 @@ class _LayoutState extends State<Layout> {
endDrawer: UserProfileBar(), endDrawer: UserProfileBar(),
floatingActionButton: widget.floatingActionButton, floatingActionButton: widget.floatingActionButton,
body: SafeArea( body: SafeArea(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
if (projectController.isProjectSelectionExpanded.value) {
projectController.isProjectSelectionExpanded.value = false;
}
},
child: Stack( child: Stack(
children: [ children: [
Column( Column(
@ -61,13 +68,12 @@ class _LayoutState extends State<Layout> {
), ),
], ],
), ),
// Overlay project list below header
Obx(() { Obx(() {
if (!projectController.isProjectSelectionExpanded.value) { if (!projectController.isProjectSelectionExpanded.value) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return Positioned( return Positioned(
top: 95, // Adjust based on header card height top: 95,
left: 16, left: 16,
right: 16, right: 16,
child: Material( child: Material(
@ -87,6 +93,7 @@ class _LayoutState extends State<Layout> {
], ],
), ),
), ),
),
); );
} }
@ -94,6 +101,12 @@ class _LayoutState extends State<Layout> {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Obx(() { child: Obx(() {
final isLoading = projectController.isLoading.value;
if (isLoading) {
return _buildLoadingSkeleton();
}
final isExpanded = projectController.isProjectSelectionExpanded.value; final isExpanded = projectController.isProjectSelectionExpanded.value;
final selectedProjectId = projectController.selectedProjectId?.value; final selectedProjectId = projectController.selectedProjectId?.value;
final selectedProject = projectController.projects.firstWhereOrNull( final selectedProject = projectController.projects.firstWhereOrNull(
@ -212,8 +225,6 @@ class _LayoutState extends State<Layout> {
], ],
), ),
), ),
// Expanded Project List inside card only show if projects exist
if (isExpanded && hasProjects) if (isExpanded && hasProjects)
Positioned( Positioned(
top: 70, top: 70,
@ -232,6 +243,56 @@ class _LayoutState extends State<Layout> {
); );
} }
Widget _buildLoadingSkeleton() {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
Container(
height: 50,
width: 50,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 18,
width: 140,
color: Colors.grey.shade300,
),
const SizedBox(height: 6),
Container(
height: 14,
width: 100,
color: Colors.grey.shade200,
),
],
),
),
const SizedBox(width: 10),
Container(
height: 30,
width: 30,
color: Colors.grey.shade300,
),
],
),
),
);
}
Widget _buildProjectList(BuildContext context, bool isMobile) { Widget _buildProjectList(BuildContext context, bool isMobile) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -12,9 +12,9 @@ import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/dashboard/daily_task_controller.dart'; import 'package:marco/controller/dashboard/daily_task_controller.dart';
import 'package:marco/model/dailyTaskPlaning/daily_progress_report_filter.dart'; import 'package:marco/model/dailyTaskPlaning/daily_progress_report_filter.dart';
import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/model/dailyTaskPlaning/comment_task_bottom_sheet.dart';
import 'package:marco/model/dailyTaskPlaning/report_task_bottom_sheet.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:marco/model/dailyTaskPlaning/task_action_buttons.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
class DailyProgressReportScreen extends StatefulWidget { class DailyProgressReportScreen extends StatefulWidget {
const DailyProgressReportScreen({super.key}); const DailyProgressReportScreen({super.key});
@ -298,7 +298,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
final groupedTasks = dailyTaskController.groupedDailyTasks; final groupedTasks = dailyTaskController.groupedDailyTasks;
if (isLoading) { if (isLoading) {
return const Center(child: CircularProgressIndicator()); return SkeletonLoaders.dailyProgressReportSkeletonLoader();
} }
if (groupedTasks.isEmpty) { if (groupedTasks.isEmpty) {
@ -369,6 +369,8 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
final activityName = final activityName =
task.workItem?.activityMaster?.activityName ?? 'N/A'; task.workItem?.activityMaster?.activityName ?? 'N/A';
final activityId = task.workItem?.activityMaster?.id;
final workAreaId = task.workItem?.workArea?.id;
final location = [ final location = [
task.workItem?.workArea?.floor?.building?.name, task.workItem?.workArea?.floor?.building?.name,
task.workItem?.workArea?.floor?.floorName, task.workItem?.workArea?.floor?.floorName,
@ -380,7 +382,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
final progress = (planned != 0) final progress = (planned != 0)
? (completed / planned).clamp(0.0, 1.0) ? (completed / planned).clamp(0.0, 1.0)
: 0.0; : 0.0;
final parentTaskID = task.id;
return Column( return Column(
children: [ children: [
Padding( Padding(
@ -459,197 +461,32 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
if (task.reportedDate == null || if (task.reportedDate == null ||
task.reportedDate.toString().isEmpty) task.reportedDate
OutlinedButton.icon( .toString()
icon: const Icon(Icons.report, .isEmpty) ...[
size: 18, TaskActionButtons.reportButton(
color: Colors.blueAccent),
label: const Text('Report',
style: TextStyle(
color: Colors.blueAccent)),
style: OutlinedButton.styleFrom(
side: const BorderSide(
color: Colors.blueAccent),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
textStyle:
const TextStyle(fontSize: 14),
),
onPressed: () {
final activityName = task
.workItem
?.activityMaster
?.activityName ??
'N/A';
final assigned =
'${(task.plannedTask - completed)}';
final assignedBy =
"${task.assignedBy.firstName} ${task.assignedBy.lastName ?? ''}";
final assignedOn =
DateFormat('dd-MM-yyyy').format(
task.assignmentDate);
final taskId = task.id;
final location = [
task.workItem?.workArea?.floor
?.building?.name,
task.workItem?.workArea?.floor
?.floorName,
task.workItem?.workArea?.areaName,
]
.where((e) =>
e != null && e.isNotEmpty)
.join(' > ');
final teamMembers = task.teamMembers
.map((e) => e.firstName)
.toList();
final pendingWork = (task.workItem
?.plannedWork ??
0) -
(task.workItem?.completedWork ??
0);
final taskData = {
'activity': activityName,
'assigned': assigned,
'taskId': taskId,
'assignedBy': assignedBy,
'completed': completed,
'assignedOn': assignedOn,
'location': location,
'teamSize':
task.teamMembers.length,
'teamMembers': teamMembers,
'pendingWork': pendingWork,
};
showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, task: task,
shape: completed: completed.toInt(),
const RoundedRectangleBorder( refreshCallback: _refreshData,
borderRadius:
BorderRadius.vertical(
top: Radius.circular(
16)),
),
builder: (BuildContext ctx) =>
Padding(
padding: MediaQuery.of(ctx)
.viewInsets,
child: ReportTaskBottomSheet(
taskData: taskData,
onReportSuccess: () {
_refreshData();
},
),
),
);
},
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
OutlinedButton.icon( ] else if (task.approvedBy == null) ...[
icon: const Icon(Icons.comment, TaskActionButtons.reportActionButton(
size: 18, color: Colors.blueAccent),
label: const Text('Comment',
style: TextStyle(
color: Colors.blueAccent)),
style: OutlinedButton.styleFrom(
side: const BorderSide(
color: Colors.blueAccent),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
textStyle:
const TextStyle(fontSize: 14),
),
onPressed: () {
final activityName = task
.workItem
?.activityMaster
?.activityName ??
'N/A';
final plannedTask = task.plannedTask;
final completed = task.completedTask;
final assigned =
'${(plannedTask - completed)}';
final plannedWork =
plannedTask.toString();
final completedWork =
completed.toString();
final assignedBy =
"${task.assignedBy.firstName} ${task.assignedBy.lastName ?? ''}";
final assignedOn =
DateFormat('yyyy-MM-dd')
.format(task.assignmentDate);
final taskId = task.id;
final location = [
task.workItem?.workArea?.floor
?.building?.name,
task.workItem?.workArea?.floor
?.floorName,
task.workItem?.workArea?.areaName,
]
.where((e) =>
e != null && e.isNotEmpty)
.join(' > ');
final teamMembers = task.teamMembers
.map((e) =>
'${e.firstName} ${e.lastName}')
.toList();
final taskComments =
task.comments.map((comment) {
final isoDate = comment.timestamp
.toIso8601String();
final commenterName = comment
.commentedBy
.firstName
.isNotEmpty
? "${comment.commentedBy.firstName} ${comment.commentedBy.lastName ?? ''}"
.trim()
: "Unknown";
return {
'text': comment.comment,
'date': isoDate,
'commentedBy': commenterName,
'preSignedUrls':
comment.preSignedUrls,
};
}).toList();
final taskLevelPreSignedUrls =
task.reportedPreSignedUrls;
final taskData = {
'activity': activityName,
'assigned': assigned,
'taskId': taskId,
'assignedBy': assignedBy,
'completedWork': completedWork,
'plannedWork': plannedWork,
'assignedOn': assignedOn,
'location': location,
'teamSize': task.teamMembers.length,
'teamMembers': teamMembers,
'taskComments': taskComments,
'reportedPreSignedUrls':
taskLevelPreSignedUrls,
};
showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, task: task,
backgroundColor: Colors.transparent, parentTaskID: parentTaskID,
builder: (_) => workAreaId: workAreaId.toString(),
CommentTaskBottomSheet( activityId: activityId.toString(),
taskData: taskData, completed: completed.toInt(),
onCommentSuccess: () { refreshCallback: _refreshData,
_refreshData();
Navigator.of(context).pop();
},
), ),
); const SizedBox(width: 8),
}, ],
TaskActionButtons.commentButton(
context: context,
task: task,
refreshCallback: _refreshData,
), ),
], ],
) )

View File

@ -11,6 +11,7 @@ import 'package:marco/controller/task_planing/daily_task_planing_controller.dart
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:percent_indicator/percent_indicator.dart'; import 'package:percent_indicator/percent_indicator.dart';
import 'package:marco/model/dailyTaskPlaning/assign_task_bottom_sheet .dart'; import 'package:marco/model/dailyTaskPlaning/assign_task_bottom_sheet .dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
class DailyTaskPlaningScreen extends StatefulWidget { class DailyTaskPlaningScreen extends StatefulWidget {
DailyTaskPlaningScreen({super.key}); DailyTaskPlaningScreen({super.key});
@ -170,7 +171,7 @@ class _DailyTaskPlaningScreenState extends State<DailyTaskPlaningScreen>
final dailyTasks = dailyTaskPlaningController.dailyTasks; final dailyTasks = dailyTaskPlaningController.dailyTasks;
if (isLoading) { if (isLoading) {
return Center(child: CircularProgressIndicator()); return SkeletonLoaders.dailyProgressPlanningSkeletonCollapsedOnly();
} }
if (dailyTasks.isEmpty) { if (dailyTasks.isEmpty) {
@ -279,10 +280,10 @@ class _DailyTaskPlaningScreenState extends State<DailyTaskPlaningScreen>
floorExpansionState[floorWorkAreaKey] ?? false; floorExpansionState[floorWorkAreaKey] ?? false;
final totalPlanned = area.workItems final totalPlanned = area.workItems
.map((wi) => wi.workItem.plannedWork ?? 0) .map((wi) => wi.workItem.plannedWork ?? 0)
.fold<int>(0, (prev, curr) => prev + curr); .fold<double>(0, (prev, curr) => prev + curr);
final totalCompleted = area.workItems final totalCompleted = area.workItems
.map((wi) => wi.workItem.completedWork ?? 0) .map((wi) => wi.workItem.completedWork ?? 0)
.fold<int>(0, (prev, curr) => prev + curr); .fold<double>(0, (prev, curr) => prev + curr);
final totalProgress = totalPlanned == 0 final totalProgress = totalPlanned == 0
? 0.0 ? 0.0
: (totalCompleted / totalPlanned).clamp(0.0, 1.0); : (totalCompleted / totalPlanned).clamp(0.0, 1.0);
@ -429,7 +430,7 @@ class _DailyTaskPlaningScreenState extends State<DailyTaskPlaningScreen>
onPressed: () { onPressed: () {
final pendingTask = final pendingTask =
(planned - completed) (planned - completed)
.clamp(0, planned); .clamp(0, planned).toInt();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,

View File

@ -98,8 +98,6 @@ flutter:
- assets/avatar/ - assets/avatar/
- assets/coin/ - assets/coin/
- assets/country/ - assets/country/
- assets/data/
- assets/dummy/
- assets/lang/ - assets/lang/
- assets/logo/ - assets/logo/
- assets/logo/loading_logo.png - assets/logo/loading_logo.png