diff --git a/lib/controller/dashboard/daily_task_controller.dart b/lib/controller/dashboard/daily_task_controller.dart index be0f5e4..64dadd6 100644 --- a/lib/controller/dashboard/daily_task_controller.dart +++ b/lib/controller/dashboard/daily_task_controller.dart @@ -15,7 +15,7 @@ class DailyTaskController extends GetxController { DateTime? endDateTask; List dailyTasks = []; - final RxSet expandedDates = {}.obs; + final RxSet expandedDates = {}.obs; void toggleDate(String dateKey) { if (expandedDates.contains(dateKey)) { @@ -25,7 +25,7 @@ class DailyTaskController extends GetxController { } } - RxBool isLoading = false.obs; + RxBool isLoading = true.obs; Map> groupedDailyTasks = {}; @override void onInit() { @@ -35,7 +35,6 @@ class DailyTaskController extends GetxController { void _initializeDefaults() { _setDefaultDateRange(); - fetchProjects(); } void _setDefaultDateRange() { @@ -45,22 +44,6 @@ class DailyTaskController extends GetxController { log.i("Default date range set: $startDateTask to $endDateTask"); } - Future fetchProjects() async { - isLoading.value = true; - - final response = await ApiService.getProjects(); - isLoading.value = false; - - if (response?.isEmpty ?? true) { - log.w("No project data found or API call failed."); - return; - } - - projects = response!.map((json) => ProjectModel.fromJson(json)).toList(); - log.i("Projects fetched: ${projects.length} projects loaded."); - update(); - } - Future fetchTaskData(String? projectId) async { if (projectId == null) return; diff --git a/lib/controller/dashboard/dashboard_controller.dart b/lib/controller/dashboard/dashboard_controller.dart index 2cd18a1..2ce16bc 100644 --- a/lib/controller/dashboard/dashboard_controller.dart +++ b/lib/controller/dashboard/dashboard_controller.dart @@ -1,54 +1,100 @@ import 'package:get/get.dart'; import 'package:logger/logger.dart'; import 'package:marco/helpers/services/api_service.dart'; -import 'package:marco/model/project_model.dart'; +import 'package:marco/controller/project_controller.dart'; final Logger log = Logger(); class DashboardController extends GetxController { - RxList projects = [].obs; - RxString? selectedProjectId; - var isProjectListExpanded = false.obs; - RxBool isProjectSelectionExpanded = true.obs; + // Observables + final RxList> roleWiseData = >[].obs; + final RxBool isLoading = false.obs; + final RxString selectedRange = '7D'.obs; + final RxBool isChartView = true.obs; - void toggleProjectListExpanded() { - isProjectListExpanded.value = !isProjectListExpanded.value; - } - - var isProjectDropdownExpanded = false.obs; - - RxBool isLoading = true.obs; - RxBool isLoadingProjects = true.obs; - RxMap uploadingStates = {}.obs; + // Inject the ProjectController + final ProjectController projectController = Get.find(); @override void onInit() { super.onInit(); - fetchProjects(); - } - /// Fetches projects and initializes selected project. - Future fetchProjects() async { - isLoadingProjects.value = true; - isLoading.value = true; + final selectedProjectIdRx = projectController.selectedProjectId; - final response = await ApiService.getProjects(); + if (selectedProjectIdRx != null) { + // Fix: explicitly cast and use ever with non-nullable type + ever(selectedProjectIdRx, (id) { + if (id.isNotEmpty) { + fetchRoleWiseAttendance(); + } + }); - if (response != null && response.isNotEmpty) { - projects.assignAll( - response.map((json) => ProjectModel.fromJson(json)).toList()); - selectedProjectId = RxString(projects.first.id.toString()); - log.i("Projects fetched: ${projects.length}"); + // Initial load if already has value + if (selectedProjectIdRx.value.isNotEmpty) { + fetchRoleWiseAttendance(); + } } else { - log.w("No projects found or API call failed."); + log.w('selectedProjectId observable is null in ProjectController.'); } - isLoadingProjects.value = false; - isLoading.value = false; - update(['dashboard_controller']); + ever(selectedRange, (_) { + fetchRoleWiseAttendance(); + }); } - void updateSelectedProject(String projectId) { - selectedProjectId?.value = projectId; + 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 refreshDashboard() async { + await fetchRoleWiseAttendance(); + } + + Future 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; + + final List? response = + await ApiService.getDashboardAttendanceOverview(projectId, rangeDays); + + if (response != null) { + roleWiseData.value = + response.map((e) => Map.from(e)).toList(); + log.i('Attendance overview fetched successfully.'); + } else { + log.e('Failed to fetch attendance overview: response is null.'); + roleWiseData.clear(); + } + } catch (e, st) { + log.e('Error fetching attendance overview', error: e, stackTrace: st); + roleWiseData.clear(); + } finally { + isLoading.value = false; + } } } diff --git a/lib/controller/project_controller.dart b/lib/controller/project_controller.dart index 7baf879..b30bcdb 100644 --- a/lib/controller/project_controller.dart +++ b/lib/controller/project_controller.dart @@ -1,13 +1,13 @@ import 'package:get/get.dart'; import 'package:logger/logger.dart'; import 'package:marco/helpers/services/api_service.dart'; -import 'package:marco/model/project_model.dart'; +import 'package:marco/model/global_project_model.dart'; import 'package:marco/helpers/services/storage/local_storage.dart'; final Logger log = Logger(); class ProjectController extends GetxController { - RxList projects = [].obs; + RxList projects = [].obs; RxString? selectedProjectId; RxBool isProjectListExpanded = false.obs; RxBool isProjectSelectionExpanded = false.obs; @@ -16,7 +16,7 @@ class ProjectController extends GetxController { RxBool isLoading = true.obs; RxBool isLoadingProjects = true.obs; RxMap uploadingStates = {}.obs; - ProjectModel? get selectedProject { + GlobalProjectModel? get selectedProject { if (selectedProjectId == null || selectedProjectId!.value.isEmpty) return null; return projects.firstWhereOrNull((p) => p.id == selectedProjectId!.value); @@ -50,7 +50,7 @@ class ProjectController extends GetxController { if (response != null && response.isNotEmpty) { projects.assignAll( - response.map((json) => ProjectModel.fromJson(json)).toList()); + response.map((json) => GlobalProjectModel.fromJson(json)).toList()); String? savedId = LocalStorage.getString('selectedProjectId'); if (savedId != null && projects.any((p) => p.id == savedId)) { diff --git a/lib/controller/task_planing/add_task_controller.dart b/lib/controller/task_planing/add_task_controller.dart new file mode 100644 index 0000000..3fa9e16 --- /dev/null +++ b/lib/controller/task_planing/add_task_controller.dart @@ -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 uploadingStates = {}.obs; + MyFormValidator basicValidator = MyFormValidator(); + RxnString selectedCategoryId = RxnString(); + RxnString selectedCategoryName = RxnString(); + var categoryIdNameMap = {}.obs; + + List> roles = []; + RxnString selectedRoleId = RxnString(); + RxBool isLoadingWorkMasterCategories = false.obs; + RxList workMasterCategories = [].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 assignDailyTask({ + required String workItemId, + required int plannedTask, + required String description, + required List 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 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 fetchWorkMasterCategories() async { + isLoadingWorkMasterCategories.value = true; + + final response = await ApiService.getMasterWorkCategories(); + if (response != null) { + try { + final dataList = response['data'] ?? []; + + final parsedList = List.from( + dataList.map((e) => WorkCategoryModel.fromJson(e)), + ); + + workMasterCategories.assignAll(parsedList); + final Map 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]; + } +} diff --git a/lib/controller/task_planing/report_task_action_controller.dart b/lib/controller/task_planing/report_task_action_controller.dart new file mode 100644 index 0000000..e01f4e6 --- /dev/null +++ b/lib/controller/task_planing/report_task_action_controller.dart @@ -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 reportStatus = ApiStatus.idle.obs; + final Rx commentStatus = ApiStatus.idle.obs; + + final RxList selectedImages = [].obs; + final RxList workStatus = [].obs; + final RxList workStatuses = [].obs; + + final RxBool showAddTaskCheckbox = false.obs; + final RxBool isAddTaskChecked = false.obs; + + final RxBool isLoadingWorkStatus = false.obs; + final Rxn> selectedTask = Rxn>(); + + 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 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 approveTask({ + required String projectId, + required String comment, + required String reportActionId, + required String approvedTaskCount, + List? 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 commentTask({ + required String projectId, + required String comment, + List? 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 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>?> _prepareImages(List? 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>().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 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); +} diff --git a/lib/controller/task_planing/report_task_controller.dart b/lib/controller/task_planing/report_task_controller.dart index ea54937..1bf4201 100644 --- a/lib/controller/task_planing/report_task_controller.dart +++ b/lib/controller/task_planing/report_task_controller.dart @@ -309,7 +309,7 @@ class ReportTaskController extends MyController { } } else { final pickedFiles = await _picker.pickMultiImage(imageQuality: 75); - if (pickedFiles != null && pickedFiles.isNotEmpty) { + if (pickedFiles.isNotEmpty) { selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path))); } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index dba0bb0..12809fd 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -2,6 +2,9 @@ class ApiEndpoints { static const String baseUrl = "https://stageapi.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 static const String getProjects = "/project/list"; static const String getGlobalProjects = "/project/list/basic"; @@ -24,4 +27,8 @@ class ApiEndpoints { static const String commentTask = "/task/comment"; static const String dailyTaskDetails = "/project/details"; 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"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 0b51c3a..bf3903e 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -11,8 +11,9 @@ import 'package:marco/helpers/services/storage/local_storage.dart'; final Logger logger = Logger(); class ApiService { - static const Duration timeout = Duration(seconds: 10); + static const Duration timeout = Duration(seconds: 30); static const bool enableLogs = true; + static const Duration extendedTimeout = Duration(seconds: 60); // === Helpers === @@ -45,20 +46,29 @@ class ApiService { return null; } - static dynamic _parseResponseForAllData(http.Response response, {String label = ''}) { - _log("$label Response: ${response.body}"); - try { - final json = jsonDecode(response.body); - if (response.statusCode == 200 && json['success'] == true) { - return json; - } - _log("API Error [$label]: ${json['message'] ?? 'Unknown error'}"); - } catch (e) { - _log("Response parsing error [$label]: $e"); +static dynamic _parseResponseForAllData(http.Response response, + {String label = ''}) { + _log("$label Response: ${response.body}"); + + try { + final body = response.body.trim(); + if (body.isEmpty) throw FormatException("Empty response body"); + + final json = jsonDecode(body); + + if (response.statusCode == 200 && json['success'] == true) { + return json; } - return null; + + _log("API Error [$label]: ${json['message'] ?? 'Unknown error'}"); + } catch (e) { + _log("Response parsing error [$label]: $e"); } + return null; +} + + static Future _getRequest( String endpoint, { Map? queryParams, @@ -67,15 +77,18 @@ class ApiService { String? token = await _getToken(); 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"); 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) { _log("Unauthorized. Attempting token refresh..."); if (await AuthService.refreshToken()) { - return await _getRequest(endpoint, queryParams: queryParams, hasRetried: true); + return await _getRequest(endpoint, + queryParams: queryParams, hasRetried: true); } _log("Token refresh failed."); } @@ -106,7 +119,8 @@ class ApiService { if (response.statusCode == 401 && !hasRetried) { _log("Unauthorized POST. Attempting token refresh..."); if (await AuthService.refreshToken()) { - return await _postRequest(endpoint, body, customTimeout: customTimeout, hasRetried: true); + return await _postRequest(endpoint, body, + customTimeout: customTimeout, hasRetried: true); } } return response; @@ -116,17 +130,36 @@ class ApiService { } } +// === Dashboard Endpoints === + + static Future?> 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 === static Future?> 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?> 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?> getEmployeesByProject(String projectId) async => - _getRequest(ApiEndpoints.getEmployeesByProject, queryParams: {"projectId": projectId}) - .then((res) => res != null ? _parseResponse(res, label: 'Employees') : null); + _getRequest(ApiEndpoints.getEmployeesByProject, + queryParams: {"projectId": projectId}) + .then((res) => + res != null ? _parseResponse(res, label: 'Employees') : null); static Future?> getAttendanceLogs( String projectId, { @@ -135,20 +168,25 @@ class ApiService { }) async { final query = { "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), }; - return _getRequest(ApiEndpoints.getAttendanceLogs, queryParams: query) - .then((res) => res != null ? _parseResponse(res, label: 'Attendance Logs') : null); + return _getRequest(ApiEndpoints.getAttendanceLogs, queryParams: query).then( + (res) => + res != null ? _parseResponse(res, label: 'Attendance Logs') : null); } static Future?> getAttendanceLogView(String id) async => - _getRequest("${ApiEndpoints.getAttendanceLogView}/$id") - .then((res) => res != null ? _parseResponse(res, label: 'Log Details') : null); + _getRequest("${ApiEndpoints.getAttendanceLogView}/$id").then((res) => + res != null ? _parseResponse(res, label: 'Log Details') : null); static Future?> getRegularizationLogs(String projectId) async => - _getRequest(ApiEndpoints.getRegularizationLogs, queryParams: {"projectId": projectId}) - .then((res) => res != null ? _parseResponse(res, label: 'Regularization Logs') : null); + _getRequest(ApiEndpoints.getRegularizationLogs, + queryParams: {"projectId": projectId}) + .then((res) => res != null + ? _parseResponse(res, label: 'Regularization Logs') + : null); static Future uploadAttendanceImage( 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; final json = jsonDecode(response.body); @@ -213,17 +255,22 @@ class ApiService { // === Employee APIs === - static Future?> getAllEmployeesByProject(String projectId) async { + static Future?> getAllEmployeesByProject( + String projectId) async { if (projectId.isEmpty) throw ArgumentError('projectId must not be empty'); 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?> 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?> 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 createEmployee({ required String firstName, @@ -239,16 +286,24 @@ class ApiService { "gender": gender, "jobRoleId": jobRoleId, }; - final response = await _postRequest(ApiEndpoints.createEmployee, body); + final response = await _postRequest( + ApiEndpoints.reportTask, + body, + customTimeout: extendedTimeout, + ); + if (response == null) return false; final json = jsonDecode(response.body); return response.statusCode == 200 && json['success'] == true; } - static Future?> getEmployeeDetails(String employeeId) async { + static Future?> getEmployeeDetails( + String employeeId) async { final url = "${ApiEndpoints.getEmployeeInfo}/$employeeId"; 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 ? data : null; } @@ -261,11 +316,13 @@ class ApiService { }) async { final query = { "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), }; - return _getRequest(ApiEndpoints.getDailyTask, queryParams: query) - .then((res) => res != null ? _parseResponse(res, label: 'Daily Tasks') : null); + return _getRequest(ApiEndpoints.getDailyTask, queryParams: query).then( + (res) => + res != null ? _parseResponse(res, label: 'Daily Tasks') : null); } static Future reportTask({ @@ -284,7 +341,12 @@ class ApiService { 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; final json = jsonDecode(response.body); if (response.statusCode == 200 && json['success'] == true) { @@ -313,11 +375,13 @@ class ApiService { return response.statusCode == 200 && json['success'] == true; } - static Future?> getDailyTasksDetails(String projectId) async { + static Future?> getDailyTasksDetails( + String projectId) async { final url = "${ApiEndpoints.dailyTaskDetails}/$projectId"; final response = await _getRequest(url); return response != null - ? _parseResponseForAllData(response, label: 'Daily Task Details') as Map? + ? _parseResponseForAllData(response, label: 'Daily Task Details') + as Map? : null; } @@ -333,7 +397,8 @@ class ApiService { "plannedTask": plannedTask, "description": description, "taskTeam": taskTeam, - "assignmentDate": (assignmentDate ?? DateTime.now()).toUtc().toIso8601String(), + "assignmentDate": + (assignmentDate ?? DateTime.now()).toUtc().toIso8601String(), }; final response = await _postRequest(ApiEndpoints.assignDailyTask, body); if (response == null) return false; @@ -345,4 +410,78 @@ class ApiService { _log("Failed to assign daily task: ${json['message'] ?? 'Unknown error'}"); return false; } + + static Future?> 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?; + } + + static Future?> getMasterWorkCategories() async => + _getRequest(ApiEndpoints.getmasterWorkCategories).then((res) => + res != null + ? _parseResponseForAllData(res, label: 'Master Work Categories') + : null); + static Future approveTask({ + required String id, + required String comment, + required String workStatus, + required int approvedTask, + List>? 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 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; + } } diff --git a/lib/helpers/widgets/my_custom_skeleton.dart b/lib/helpers/widgets/my_custom_skeleton.dart new file mode 100644 index 0000000..1ba3a3e --- /dev/null +++ b/lib/helpers/widgets/my_custom_skeleton.dart @@ -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, + ), + ), + ], + ), + ); + }), + ); + } +} diff --git a/lib/model/attendance_model.dart b/lib/model/attendance_model.dart index 0d6f6a1..9a4ef92 100644 --- a/lib/model/attendance_model.dart +++ b/lib/model/attendance_model.dart @@ -6,8 +6,8 @@ class AttendanceModel { final DateTime startDate; final DateTime endDate; final int teamSize; - final int completedWork; - final int plannedWork; + final double completedWork; + final double plannedWork; AttendanceModel({ required this.id, @@ -30,8 +30,8 @@ class AttendanceModel { startDate: DateTime.tryParse(json['startDate']?.toString() ?? '') ?? DateTime.now(), endDate: DateTime.tryParse(json['endDate']?.toString() ?? '') ?? DateTime.now(), teamSize: int.tryParse(json['teamSize']?.toString() ?? '') ?? 0, - completedWork: int.tryParse(json['completedWork']?.toString() ?? '') ?? 0, - plannedWork: int.tryParse(json['plannedWork']?.toString() ?? '') ?? 0, + completedWork: double.tryParse(json['completedWork']?.toString() ?? '') ?? 0, + plannedWork: double.tryParse(json['plannedWork']?.toString() ?? '') ?? 0, ); } diff --git a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart index 281e37a..a968260 100644 --- a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart @@ -11,6 +11,7 @@ import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/my_team_model_sheet.dart'; import 'package:intl/intl.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; +import 'dart:io'; class CommentTaskBottomSheet extends StatefulWidget { final Map taskData; @@ -182,87 +183,12 @@ class _CommentTaskBottomSheetState extends State if ((widget.taskData['reportedPreSignedUrls'] as List?) ?.isNotEmpty == - true) ...[ - MySpacing.height(8), - Padding( - padding: - 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, - ), - ], - ), + true) + buildReportedImagesSection( + imageUrls: List.from( + widget.taskData['reportedPreSignedUrls'] ?? []), + context: context, ), - - MySpacing.height(8), - - Builder( - builder: (context) { - final allImageUrls = List.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, - 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( children: [ Icon(Icons.comment_outlined, @@ -313,184 +239,51 @@ class _CommentTaskBottomSheetState extends State ), Obx(() { final images = controller.selectedImages; - 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: (context, index) => - SizedBox(height: 12), - itemBuilder: (context, index) { - final file = images[index]; - return Stack( - children: [ - GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (_) => - ImageViewerDialog( - imageSources: images, - 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), - ), - ), - ), - ], - ); - }, - ), + + return buildImagePickerSection( + images: images, + onCameraTap: () => + controller.pickImages(fromCamera: true), + onUploadTap: () => + controller.pickImages(fromCamera: false), + onRemoveImage: (index) => + controller.removeImageAt(index), + onPreviewImage: (index) { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: images, + initialIndex: index, ), - 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), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - MyButton.text( - 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( - projectId: controller.basicValidator - .getController('task_id') - ?.text ?? - '', - comment: controller.basicValidator - .getController('comment') - ?.text ?? - '', - images: controller.selectedImages, - ); - - if (widget.onCommentSuccess != null) { - widget.onCommentSuccess!(); - } - } - }, - elevation: 0, - padding: MySpacing.xy(20, 16), - backgroundColor: Colors.blueAccent, - borderRadiusAll: AppStyle.buttonRadius.medium, - child: controller.isLoading.value - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation( - contentTheme.onPrimary), - ), - ) - : MyText.bodySmall( - 'Comment', - color: contentTheme.onPrimary, - ), + buildCommentActionButtons( + onCancel: () => Navigator.of(context).pop(), + onSubmit: () async { + if (controller.basicValidator.validateForm()) { + await controller.commentTask( + projectId: controller.basicValidator + .getController('task_id') + ?.text ?? + '', + comment: controller.basicValidator + .getController('comment') + ?.text ?? + '', + images: controller.selectedImages, ); - }), - ], + if (widget.onCommentSuccess != null) { + widget.onCommentSuccess!(); + } + } + }, + isLoading: controller.isLoading, + splashColor: contentTheme.secondary.withAlpha(25), + backgroundColor: Colors.blueAccent, + loadingIndicatorColor: contentTheme.onPrimary, ), MySpacing.height(10), if ((widget.taskData['taskComments'] as List?) @@ -508,257 +301,15 @@ class _CommentTaskBottomSheetState extends State ), ], ), - Divider(), MySpacing.height(12), Builder( builder: (context) { final comments = List>.from( widget.taskData['taskComments'] as List, ); - - 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); // descending: newest first - }); - - return SizedBox( - height: 300, - child: ListView.builder( - padding: const EdgeInsets.symmetric( - vertical: - 8), // Added padding around the list - 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.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( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: - 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, - color: Colors.black87, - ), - ], - ), - const SizedBox(height: 8), - SizedBox( - height: 60, - child: ListView.separated( - padding: const EdgeInsets - .symmetric( - horizontal: 0), - 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), - ], - ], - ), - ), - ], - ), - ); - }, - ), - ); + return buildCommentList(comments, context); }, - ), + ) ], ], ), @@ -772,6 +323,79 @@ class _CommentTaskBottomSheetState extends State ); } + Widget buildReportedImagesSection({ + required List imageUrls, + required BuildContext context, + String title = "Reported Images", + }) { + if (imageUrls.isEmpty) return const SizedBox(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(8), + Padding( + padding: 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( + title, + fontWeight: 600, + ), + ], + ), + ), + MySpacing.height(8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: SizedBox( + height: 70, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: imageUrls.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final url = imageUrls[index]; + return GestureDetector( + onTap: () { + showDialog( + context: context, + barrierColor: Colors.black54, + builder: (_) => ImageViewerDialog( + imageSources: imageUrls, + 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), + ], + ); + } + Widget buildTeamMembers() { final teamMembersText = controller.basicValidator.getController('team_members')?.text ?? ''; @@ -837,6 +461,53 @@ class _CommentTaskBottomSheetState extends State ); } + Widget buildCommentActionButtons({ + required VoidCallback onCancel, + required Future 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( + loadingIndicatorColor, + ), + ), + ) + : MyText.bodySmall( + 'Comment', + color: loadingIndicatorColor, + ), + ); + }), + ], + ); + } + Widget buildRow(String label, String? value, {IconData? icon}) { return Padding( padding: const EdgeInsets.only(bottom: 16), @@ -860,4 +531,281 @@ class _CommentTaskBottomSheetState extends State ), ); } + + Widget buildCommentList( + List> 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.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 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), + ], + ), + ), + ), + ], + ), + ], + ); + } } diff --git a/lib/model/dailyTaskPlaning/create_task_botom_sheet.dart b/lib/model/dailyTaskPlaning/create_task_botom_sheet.dart new file mode 100644 index 0000000..2ca2e3f --- /dev/null +++ b/lib/model/dailyTaskPlaning/create_task_botom_sheet.dart @@ -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( + padding: EdgeInsets.zero, + onSelected: (val) { + controller.selectCategory(val); + onCategoryChanged(val); + }, + itemBuilder: (context) => categoryMap.entries + .map( + (entry) => PopupMenuItem( + 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 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]), + ], + ), + ), + ], + ), + ); +} diff --git a/lib/model/dailyTaskPlaning/daily_task_planing_model.dart b/lib/model/dailyTaskPlaning/daily_task_planing_model.dart index da2085b..f1e5e64 100644 --- a/lib/model/dailyTaskPlaning/daily_task_planing_model.dart +++ b/lib/model/dailyTaskPlaning/daily_task_planing_model.dart @@ -127,8 +127,8 @@ class WorkItem { final WorkAreaBasic? workArea; final ActivityMaster? activityMaster; final WorkCategoryMaster? workCategoryMaster; - final int? plannedWork; - final int? completedWork; + final double? plannedWork; + final double? completedWork; final DateTime? taskDate; final String? tenantId; final Tenant? tenant; @@ -165,8 +165,12 @@ class WorkItem { ? WorkCategoryMaster.fromJson( json['workCategoryMaster'] as Map) : null, - plannedWork: json['plannedWork'] as int?, - completedWork: json['completedWork'] as int?, + plannedWork: json['plannedWork'] != null + ? (json['plannedWork'] as num).toDouble() + : null, + completedWork: json['completedWork'] != null + ? (json['completedWork'] as num).toDouble() + : null, taskDate: json['taskDate'] != null ? DateTime.tryParse(json['taskDate']) : null, tenantId: json['tenantId'] as String?, diff --git a/lib/model/dailyTaskPlaning/master_work_category_model.dart b/lib/model/dailyTaskPlaning/master_work_category_model.dart new file mode 100644 index 0000000..bf87dd0 --- /dev/null +++ b/lib/model/dailyTaskPlaning/master_work_category_model.dart @@ -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 json) { + return WorkCategoryModel( + id: json['id'] ?? '', + name: json['name'] ?? '', + description: json['description'] ?? '', + isSystem: json['isSystem'] ?? false, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'isSystem': isSystem, + }; + } +} diff --git a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart new file mode 100644 index 0000000..893d6c0 --- /dev/null +++ b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart @@ -0,0 +1,1034 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/task_planing/report_task_action_controller.dart'; +import 'package:marco/helpers/theme/app_theme.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_text.dart'; +import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/helpers/widgets/my_team_model_sheet.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; +import 'package:marco/model/dailyTaskPlaning/create_task_botom_sheet.dart'; +import 'dart:io'; + +class ReportActionBottomSheet extends StatefulWidget { + final Map taskData; + final VoidCallback? onCommentSuccess; + final String taskDataId; + final String workAreaId; + final String activityId; + final VoidCallback onReportSuccess; + + const ReportActionBottomSheet({ + super.key, + required this.taskData, + this.onCommentSuccess, + required this.taskDataId, + required this.workAreaId, + required this.activityId, + required this.onReportSuccess, + }); + + @override + State createState() => + _ReportActionBottomSheetState(); +} + +class _Member { + final String firstName; + _Member(this.firstName); +} + +class _ReportActionBottomSheetState extends State + with UIMixin { + late ReportTaskActionController controller; + + final ScrollController _scrollController = ScrollController(); + String selectedAction = 'Select Action'; + @override + void initState() { + super.initState(); + controller = Get.put( + ReportTaskActionController(), + tag: widget.taskData['taskId'] ?? '', + ); + controller.fetchWorkStatuses(); + controller.basicValidator.getController('approved_task')?.text = + widget.taskData['approvedTask']?.toString() ?? ''; + final data = widget.taskData; + controller.basicValidator.getController('assigned_date')?.text = + data['assignedOn'] ?? ''; + controller.basicValidator.getController('assigned_by')?.text = + data['assignedBy'] ?? ''; + controller.basicValidator.getController('work_area')?.text = + data['location'] ?? ''; + controller.basicValidator.getController('activity')?.text = + data['activity'] ?? ''; + controller.basicValidator.getController('planned_work')?.text = + data['plannedWork'] ?? ''; + controller.basicValidator.getController('completed_work')?.text = + data['completedWork'] ?? ''; + controller.basicValidator.getController('team_members')?.text = + (data['teamMembers'] as List).join(', '); + controller.basicValidator.getController('assigned')?.text = + data['assigned'] ?? ''; + controller.basicValidator.getController('task_id')?.text = + data['taskId'] ?? ''; + controller.basicValidator.getController('comment')?.clear(); + controller.basicValidator.getController('task_id')?.text = + widget.taskDataId; + + controller.selectedImages.clear(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } + }); + } + + String timeAgo(String dateString) { + try { + DateTime date = DateTime.parse(dateString + "Z").toLocal(); + final now = DateTime.now(); + final difference = now.difference(date); + if (difference.inDays > 8) { + return DateFormat('dd-MM-yyyy').format(date); + } else if (difference.inDays >= 1) { + return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago'; + } else if (difference.inHours >= 1) { + return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago'; + } else if (difference.inMinutes >= 1) { + return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago'; + } else { + return 'just now'; + } + } catch (e) { + print('Error parsing date: $e'); + return ''; + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: SingleChildScrollView( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + left: 24, + right: 24, + top: 12, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.grey.shade400, + borderRadius: BorderRadius.circular(2), + ), + ), + GetBuilder( + tag: widget.taskData['taskId'] ?? '', + builder: (controller) { + return Form( + key: controller.basicValidator.formKey, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MyText.titleMedium( + "Take Report Action", + fontWeight: 600, + fontSize: 18, + ), + ], + ), + MySpacing.height(24), + buildRow( + "Assigned By", + controller.basicValidator + .getController('assigned_by') + ?.text + .trim(), + icon: Icons.person_outline, + ), + buildRow( + "Work Area", + controller.basicValidator + .getController('work_area') + ?.text + .trim(), + icon: Icons.place_outlined, + ), + buildRow( + "Activity", + controller.basicValidator + .getController('activity') + ?.text + .trim(), + icon: Icons.assignment_outlined, + ), + buildRow( + "Planned Work", + controller.basicValidator + .getController('planned_work') + ?.text + .trim(), + icon: Icons.schedule_outlined, + ), + buildRow( + "Completed Work", + controller.basicValidator + .getController('completed_work') + ?.text + .trim(), + icon: Icons.done_all_outlined, + ), + buildTeamMembers(), + MySpacing.height(8), + Row( + children: [ + Icon(Icons.check_circle_outline, + size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall( + "Approved Task:", + fontWeight: 600, + ), + ], + ), + MySpacing.height(10), + TextFormField( + controller: controller.basicValidator + .getController('approved_task'), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) + return 'Required'; + if (int.tryParse(value) == null) + return 'Must be a number'; + return null; + }, + decoration: InputDecoration( + hintText: "eg: 5", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + border: outlineInputBorder, + enabledBorder: outlineInputBorder, + focusedBorder: focusedInputBorder, + contentPadding: MySpacing.all(16), + isCollapsed: true, + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ), + MySpacing.height(10), + + if ((widget.taskData['reportedPreSignedUrls'] + as List?) + ?.isNotEmpty == + true) + buildReportedImagesSection( + imageUrls: List.from( + widget.taskData['reportedPreSignedUrls'] ?? []), + context: context, + ), + MySpacing.height(10), + // Add this in your stateful widget + MyText.titleSmall( + "Report Actions", + fontWeight: 600, + ), + MySpacing.height(10), + + Obx(() { + final isLoading = + controller.isLoadingWorkStatus.value; + final workStatuses = controller.workStatus; + + if (isLoading) { + return const Center( + child: CircularProgressIndicator()); + } + + return PopupMenuButton( + onSelected: (String value) { + controller.selectedWorkStatusName.value = value; + controller.showAddTaskCheckbox.value = true; + }, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + itemBuilder: (BuildContext context) { + return workStatuses.map((status) { + final statusName = status.name; + + return PopupMenuItem( + value: statusName, + child: Row( + children: [ + Radio( + value: statusName, + groupValue: controller + .selectedWorkStatusName.value, + onChanged: (_) => + Navigator.pop(context, statusName), + ), + const SizedBox(width: 8), + MyText.bodySmall(statusName), + ], + ), + ); + }).toList(); + }, + child: Container( + padding: MySpacing.xy(16, 12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular( + AppStyle.buttonRadius.medium), + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + MyText.bodySmall( + controller.selectedWorkStatusName.value + .isEmpty + ? "Select Work Status" + : controller + .selectedWorkStatusName.value, + color: Colors.black87, + ), + const Icon(Icons.arrow_drop_down, size: 20), + ], + ), + ), + ); + }), + MySpacing.height(10), + Obx(() { + if (!controller.showAddTaskCheckbox.value) + return SizedBox.shrink(); + + final checkboxTheme = CheckboxThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(2)), + side: WidgetStateBorderSide.resolveWith((states) => + BorderSide( + color: states.contains(WidgetState.selected) + ? Colors.transparent + : Colors.black)), + fillColor: WidgetStateProperty.resolveWith( + (states) => + states.contains(WidgetState.selected) + ? Colors.blueAccent + : Colors.white), + checkColor: + WidgetStateProperty.all(Colors.white), + ); + + return Theme( + data: Theme.of(context) + .copyWith(checkboxTheme: checkboxTheme), + child: CheckboxListTile( + title: MyText.titleSmall( + "Add new task", + fontWeight: 600, + ), + value: controller.isAddTaskChecked.value, + onChanged: (val) => controller + .isAddTaskChecked.value = val ?? false, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + ); + }), + MySpacing.height(10), + Row( + children: [ + Icon(Icons.comment_outlined, + size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall( + "Comment:", + fontWeight: 600, + ), + ], + ), + MySpacing.height(8), + TextFormField( + validator: controller.basicValidator + .getValidation('comment'), + controller: controller.basicValidator + .getController('comment'), + keyboardType: TextInputType.text, + decoration: InputDecoration( + hintText: "eg: Work done successfully", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + border: outlineInputBorder, + enabledBorder: outlineInputBorder, + focusedBorder: focusedInputBorder, + contentPadding: MySpacing.all(16), + isCollapsed: true, + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ), + MySpacing.height(16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.camera_alt_outlined, + size: 18, color: Colors.grey[700]), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall("Attach Photos:", + fontWeight: 600), + MySpacing.height(12), + ], + ), + ), + ], + ), + Obx(() { + final images = controller.selectedImages; + + return buildImagePickerSection( + images: images, + onCameraTap: () => + controller.pickImages(fromCamera: true), + onUploadTap: () => + controller.pickImages(fromCamera: false), + onRemoveImage: (index) => + controller.removeImageAt(index), + onPreviewImage: (index) { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: images, + initialIndex: index, + ), + ); + }, + ); + }), + MySpacing.height(24), + buildCommentActionButtons( + onCancel: () => Navigator.of(context).pop(), + onSubmit: () async { + if (controller.basicValidator.validateForm()) { + final selectedStatusName = + controller.selectedWorkStatusName.value; + final selectedStatus = + controller.workStatus.firstWhereOrNull( + (status) => status.name == selectedStatusName, + ); + + final reportActionId = + selectedStatus?.id.toString() ?? ''; + final approvedTaskCount = controller + .basicValidator + .getController('approved_task') + ?.text + .trim() ?? + ''; + + final shouldShowAddTaskSheet = + controller.isAddTaskChecked.value; + + final success = await controller.approveTask( + projectId: controller.basicValidator + .getController('task_id') + ?.text ?? + '', + comment: controller.basicValidator + .getController('comment') + ?.text ?? + '', + images: controller.selectedImages, + reportActionId: reportActionId, + approvedTaskCount: approvedTaskCount, + ); + + if (success) { + Navigator.of(context).pop(); + if (shouldShowAddTaskSheet) { + await Future.delayed( + Duration(milliseconds: 100)); + showCreateTaskBottomSheet( + workArea: widget.taskData['location'] ?? '', + activity: widget.taskData['activity'] ?? '', + completedWork: + widget.taskData['completedWork'] ?? '', + unit: widget.taskData['unit'] ?? '', + onCategoryChanged: (category) { + debugPrint( + "Category changed to: $category"); + }, + parentTaskId: widget.taskDataId, + plannedTask: int.tryParse( + widget.taskData['plannedWork'] ?? + '0') ?? + 0, + activityId: + widget.activityId, + workAreaId: + widget.workAreaId, + onSubmit: () { + Navigator.of(context).pop(); + }, + ); + } + widget.onReportSuccess.call(); + } + } + }, + isLoading: controller.isLoading, + splashColor: contentTheme.secondary.withAlpha(25), + backgroundColor: Colors.blueAccent, + loadingIndicatorColor: contentTheme.onPrimary, + ), + + MySpacing.height(10), + if ((widget.taskData['taskComments'] as List?) + ?.isNotEmpty == + true) ...[ + Row( + children: [ + MySpacing.width(10), + Icon(Icons.chat_bubble_outline, + size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall( + "Comments", + fontWeight: 600, + ), + ], + ), + MySpacing.height(12), + Builder( + builder: (context) { + final comments = List>.from( + widget.taskData['taskComments'] as List, + ); + return buildCommentList(comments, context); + }, + ) + ], + ], + ), + ), + ); + }, + ), + ], + ), + ), + ); + } + + Widget buildReportedImagesSection({ + required List imageUrls, + required BuildContext context, + String title = "Reported Images", + }) { + if (imageUrls.isEmpty) return const SizedBox(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(8), + Padding( + padding: 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( + title, + fontWeight: 600, + ), + ], + ), + ), + MySpacing.height(8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: SizedBox( + height: 70, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: imageUrls.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final url = imageUrls[index]; + return GestureDetector( + onTap: () { + showDialog( + context: context, + barrierColor: Colors.black54, + builder: (_) => ImageViewerDialog( + imageSources: imageUrls, + 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), + ], + ); + } + + Widget buildTeamMembers() { + final teamMembersText = + controller.basicValidator.getController('team_members')?.text ?? ''; + final members = teamMembersText + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + MyText.titleSmall( + "Team Members:", + fontWeight: 600, + ), + MySpacing.width(12), + GestureDetector( + onTap: () { + TeamBottomSheet.show( + context: context, + teamMembers: members.map((name) => _Member(name)).toList(), + ); + }, + child: SizedBox( + height: 32, + width: 100, + child: Stack( + children: [ + for (int i = 0; i < members.length.clamp(0, 3); i++) + Positioned( + left: i * 24.0, + child: Tooltip( + message: members[i], + child: Avatar( + firstName: members[i], + lastName: '', + size: 32, + ), + ), + ), + if (members.length > 3) + Positioned( + left: 2 * 24.0, + child: CircleAvatar( + radius: 16, + backgroundColor: Colors.grey.shade300, + child: MyText.bodyMedium( + '+${members.length - 3}', + style: const TextStyle( + fontSize: 12, color: Colors.black87), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget buildCommentActionButtons({ + required VoidCallback onCancel, + required Future 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( + loadingIndicatorColor, + ), + ), + ) + : MyText.bodySmall( + 'Submit Report', + color: loadingIndicatorColor, + ), + ); + }), + ], + ); + } + + Widget buildRow(String label, String? value, {IconData? icon}) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) + Padding( + padding: const EdgeInsets.only(right: 8.0, top: 2), + child: Icon(icon, size: 18, color: Colors.grey[700]), + ), + MyText.titleSmall( + "$label:", + fontWeight: 600, + ), + MySpacing.width(12), + Expanded( + child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"), + ), + ], + ), + ); + } + + Widget buildCommentList( + List> 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.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, + maxLines: null, + ), + ), + ], + ), + 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 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), + ], + ), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/model/dailyTaskPlaning/task_action_buttons.dart b/lib/model/dailyTaskPlaning/task_action_buttons.dart new file mode 100644 index 0000000..fd2a5fe --- /dev/null +++ b/lib/model/dailyTaskPlaning/task_action_buttons.dart @@ -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 _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, + }; + } +} diff --git a/lib/model/dailyTaskPlaning/work_status_model.dart b/lib/model/dailyTaskPlaning/work_status_model.dart new file mode 100644 index 0000000..91feab7 --- /dev/null +++ b/lib/model/dailyTaskPlaning/work_status_model.dart @@ -0,0 +1,53 @@ +class WorkStatusResponseModel { + final bool success; + final String message; + final List 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 json) { + return WorkStatusResponseModel( + success: json['success'], + message: json['message'], + data: List.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 json) { + return WorkStatus( + id: json['id'], + name: json['name'], + description: json['description'], + isSystem: json['isSystem'], + ); + } +} diff --git a/lib/model/daily_task_model.dart b/lib/model/daily_task_model.dart index 57bf8f0..4c93b88 100644 --- a/lib/model/daily_task_model.dart +++ b/lib/model/daily_task_model.dart @@ -4,9 +4,10 @@ class TaskModel { final String id; final WorkItem? workItem; final String workItemId; - final int plannedTask; - final int completedTask; + final double plannedTask; + final double completedTask; final AssignedBy assignedBy; + final AssignedBy? approvedBy; final List teamMembers; final List comments; final List reportedPreSignedUrls; @@ -20,6 +21,7 @@ class TaskModel { required this.plannedTask, required this.completedTask, required this.assignedBy, + this.approvedBy, required this.teamMembers, required this.comments, required this.reportedPreSignedUrls, @@ -35,9 +37,12 @@ class TaskModel { workItem: json['workItem'] != null ? WorkItem.fromJson(json['workItem']) : null, workItemId: json['workItemId'], - plannedTask: json['plannedTask'], - completedTask: json['completedTask'], + plannedTask: (json['plannedTask'] as num).toDouble(), + completedTask: (json['completedTask'] as num).toDouble(), assignedBy: AssignedBy.fromJson(json['assignedBy']), + approvedBy: json['approvedBy'] != null + ? AssignedBy.fromJson(json['approvedBy']) + : null, teamMembers: (json['teamMembers'] as List) .map((e) => TeamMember.fromJson(e)) .toList(), @@ -55,8 +60,8 @@ class WorkItem { final String? id; final ActivityMaster? activityMaster; final WorkArea? workArea; - final int? plannedWork; - final int? completedWork; + final double? plannedWork; + final double? completedWork; final List preSignedUrls; WorkItem({ @@ -76,8 +81,8 @@ class WorkItem { : null, workArea: json['workArea'] != null ? WorkArea.fromJson(json['workArea']) : null, - plannedWork: json['plannedWork'], - completedWork: json['completedWork'], + plannedWork: (json['plannedWork'] as num?)?.toDouble(), + completedWork: (json['completedWork'] as num?)?.toDouble(), preSignedUrls: (json['preSignedUrls'] as List?) ?.map((e) => e.toString()) .toList() ?? @@ -87,23 +92,36 @@ class WorkItem { } class ActivityMaster { + final String? id; // ✅ Added final String activityName; - ActivityMaster({required this.activityName}); + ActivityMaster({ + this.id, + required this.activityName, + }); factory ActivityMaster.fromJson(Map json) { - return ActivityMaster(activityName: json['activityName'] ?? ''); + return ActivityMaster( + id: json['id']?.toString(), + activityName: json['activityName'] ?? '', + ); } } class WorkArea { + final String? id; // ✅ Added final String areaName; final Floor? floor; - WorkArea({required this.areaName, this.floor}); + WorkArea({ + this.id, + required this.areaName, + this.floor, + }); factory WorkArea.fromJson(Map json) { return WorkArea( + id: json['id']?.toString(), areaName: json['areaName'] ?? '', floor: json['floor'] != null ? Floor.fromJson(json['floor']) : null, ); diff --git a/lib/model/dashboard/attendance_overview_model.dart b/lib/model/dashboard/attendance_overview_model.dart new file mode 100644 index 0000000..3124e3f --- /dev/null +++ b/lib/model/dashboard/attendance_overview_model.dart @@ -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 json) { + return AttendanceOverview( + role: json['role'] ?? '', + date: json['date'] ?? '', + present: json['present'] ?? 0, + ); + } +} diff --git a/lib/model/employees/add_employee_bottom_sheet.dart b/lib/model/employees/add_employee_bottom_sheet.dart index c690514..fa0c8d7 100644 --- a/lib/model/employees/add_employee_bottom_sheet.dart +++ b/lib/model/employees/add_employee_bottom_sheet.dart @@ -1,361 +1,103 @@ import 'package:flutter/material.dart'; import 'package:get/get.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/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; -import 'package:marco/controller/dashboard/employees_screen_controller.dart'; + class AddEmployeeBottomSheet extends StatefulWidget { @override - _AddEmployeeBottomSheetState createState() => _AddEmployeeBottomSheetState(); + State createState() => _AddEmployeeBottomSheetState(); } -class _AddEmployeeBottomSheetState extends State - with UIMixin { - final AddEmployeeController controller = Get.put(AddEmployeeController()); +class _AddEmployeeBottomSheetState extends State with UIMixin { + final AddEmployeeController _controller = Get.put(AddEmployeeController()); + + late TextEditingController genderController; + late TextEditingController roleController; @override - Widget build(BuildContext context) { - final theme = Theme.of(context); + void initState() { + super.initState(); + genderController = TextEditingController(); + roleController = TextEditingController(); + } - return GetBuilder( - init: controller, - builder: (_) { - 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), + RelativeRect _popupMenuPosition(BuildContext context) { + final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox; + return RelativeRect.fromLTRB(100, 300, overlay.size.width - 100, 0); + } - Form( - key: controller.basicValidator.formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildLabel("First Name"), - MySpacing.height(8), - _buildTextField( - 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>( - 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(), - ), - ), - ), - ), - ], - 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(); - final minLength = - controller.minDigitsPerCountry[ - controller - .selectedCountryCode] ?? - 7; - 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( - 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( - value: gender, - child: MyText.labelMedium( - gender.name.capitalizeFirst!), - ), - ) - .toList(), - onChanged: controller.onGenderSelected, - ), - MySpacing.height(24), - - _buildLabel("Select Role"), - MySpacing.height(8), - DropdownButtonFormField( - 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( - value: role['id'], - child: Text(role['name']), - ), - ) - .toList(), - onChanged: controller.onRoleSelected, - ), - MySpacing.height(24), - - // Buttons row - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - MyButton.text( - onPressed: () => Navigator.pop(context), - padding: MySpacing.xy(20, 16), - 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(); - 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), - ), - ], - ), - ], - ), - ), - ], - ), - ), + void _showGenderPopup(BuildContext context) async { + final selected = await showMenu( + context: context, + position: _popupMenuPosition(context), + items: Gender.values.map((gender) { + return PopupMenuItem( + value: gender, + child: Text(gender.name.capitalizeFirst!), ); - }, + }).toList(), + ); + + if (selected != null) { + _controller.onGenderSelected(selected); + _controller.update(); + } + } + + void _showRolePopup(BuildContext context) async { + final selected = await showMenu( + context: context, + position: _popupMenuPosition(context), + items: _controller.roles.map((role) { + return PopupMenuItem( + value: role['id'], + child: Text(role['name']), + ); + }).toList(), + ); + + if (selected != null) { + _controller.onRoleSelected(selected); + _controller.update(); + } + } + + Widget _sectionLabel(String title) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelLarge(title, fontWeight: 600), + MySpacing.height(4), + Divider(thickness: 1, color: Colors.grey.shade200), + ], ); } - Widget _buildLabel(String text) => MyText.labelMedium(text); - - Widget _buildTextField({ + Widget _inputWithIcon({ + required String label, + required String hint, + required IconData icon, required TextEditingController controller, required String? Function(String?)? validator, - required String hint, - TextInputType keyboardType = TextInputType.text, }) { - return TextFormField( - controller: controller, - validator: validator, - keyboardType: keyboardType, - decoration: _inputDecoration(hint), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium(label), + MySpacing.height(8), + TextFormField( + controller: controller, + validator: validator, + decoration: _inputDecoration(hint).copyWith( + prefixIcon: Icon(icon, size: 20), + ), + ), + ], ); } @@ -363,12 +105,258 @@ class _AddEmployeeBottomSheetState extends State return InputDecoration( hintText: hint, hintStyle: MyTextStyle.bodySmall(xMuted: true), - border: outlineInputBorder, - enabledBorder: outlineInputBorder, - focusedBorder: focusedInputBorder, + filled: true, + fillColor: Colors.grey.shade100, + 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), - isCollapsed: true, - floatingLabelBehavior: FloatingLabelBehavior.never, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return GetBuilder( + 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>( + 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(); + 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)), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, ); } } diff --git a/lib/model/global_project_model.dart b/lib/model/global_project_model.dart new file mode 100644 index 0000000..5d1c6b5 --- /dev/null +++ b/lib/model/global_project_model.dart @@ -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 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 toJson() { +return { +'id': id, +'name': name, +'projectAddress': projectAddress, +'contactPerson': contactPerson, +'startDate': startDate.toIso8601String(), +'endDate': endDate.toIso8601String(), +'teamSize': teamSize, +'projectStatusId': projectStatusId, +'tenantId': tenantId, +}; +} +} \ No newline at end of file diff --git a/lib/model/project_model.dart b/lib/model/project_model.dart index 066e2ac..9498cf4 100644 --- a/lib/model/project_model.dart +++ b/lib/model/project_model.dart @@ -6,10 +6,10 @@ class ProjectModel { final DateTime startDate; final DateTime endDate; final int teamSize; - final int completedWork; - final int plannedWork; + final double completedWork; + final double plannedWork; final String projectStatusId; - final String? tenantId; + final String? tenantId; ProjectModel({ required this.id, @@ -35,10 +35,14 @@ class ProjectModel { startDate: DateTime.parse(json['startDate']), endDate: DateTime.parse(json['endDate']), teamSize: json['teamSize'], - completedWork: json['completedWork'], - plannedWork: json['plannedWork'], + completedWork: json['completedWork'] != null + ? (json['completedWork'] as num).toDouble() + : 0.0, + plannedWork: json['plannedWork'] != null + ? (json['plannedWork'] as num).toDouble() + : 0.0, projectStatusId: json['projectStatusId'], - tenantId: json['tenantId'], + tenantId: json['tenantId'], ); } diff --git a/lib/view/dashboard/Attendence/attendance_screen.dart b/lib/view/dashboard/Attendence/attendance_screen.dart index 0271cfa..3974484 100644 --- a/lib/view/dashboard/Attendence/attendance_screen.dart +++ b/lib/view/dashboard/Attendence/attendance_screen.dart @@ -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/attendence_filter_sheet.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; class AttendanceScreen extends StatefulWidget { AttendanceScreen({super.key}); @@ -76,9 +77,9 @@ class _AttendanceScreenState extends State with UIMixin { elevation: 0.5, foregroundColor: Colors.black, titleSpacing: 0, - centerTitle: false, + centerTitle: false, leading: Padding( - padding: const EdgeInsets.only(top: 15.0), + padding: const EdgeInsets.only(top: 15.0), child: IconButton( icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), @@ -88,7 +89,7 @@ class _AttendanceScreenState extends State with UIMixin { ), ), title: Padding( - padding: const EdgeInsets.only(top: 15.0), + padding: const EdgeInsets.only(top: 15.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, @@ -304,10 +305,7 @@ class _AttendanceScreenState extends State with UIMixin { ), ), if (isLoading) - const SizedBox( - height: 120, - child: Center(child: CircularProgressIndicator()), - ) + SkeletonLoaders.employeeListSkeletonLoader() else if (employees.isEmpty) SizedBox( height: 120, @@ -503,10 +501,7 @@ class _AttendanceScreenState extends State with UIMixin { ), ), if (attendanceController.isLoadingAttendanceLogs.value) - const SizedBox( - height: 120, - child: Center(child: CircularProgressIndicator()), - ) + SkeletonLoaders.employeeListSkeletonLoader() else if (logs.isEmpty) SizedBox( height: 120, @@ -691,10 +686,7 @@ class _AttendanceScreenState extends State with UIMixin { Obx(() { final employees = attendanceController.regularizationLogs; if (attendanceController.isLoadingRegularizationLogs.value) { - return SizedBox( - height: 120, - child: const Center(child: CircularProgressIndicator()), - ); + return SkeletonLoaders.employeeListSkeletonLoader(); } if (employees.isEmpty) { diff --git a/lib/view/dashboard/dashboard_chart.dart b/lib/view/dashboard/dashboard_chart.dart new file mode 100644 index 0000000..d4edec1 --- /dev/null +++ b/lib/view/dashboard/dashboard_chart.dart @@ -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(); + + AttendanceDashboardChart({super.key}); + + List> 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 get filteredDateTimes { + final uniqueDates = filteredData + .map((e) => DateTime.parse(e['date'] as String)) + .toSet() + .toList() + ..sort(); + return uniqueDates; + } + + List get filteredDates => + filteredDateTimes.map((d) => DateFormat('d MMMM').format(d)).toList(); + + List get filteredRoles => + filteredData.map((e) => e['role'] as String).toSet().toList(); + + final Map _roleColorMap = {}; + final List 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( + 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, 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), + ); + } +} diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 5227333..bb3e0b6 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -1,19 +1,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.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/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_container.dart'; import 'package:marco/helpers/widgets/my_spacing.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/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 { const DashboardScreen({super.key}); + static const String dashboardRoute = "/dashboard"; static const String employeesRoute = "/dashboard/employees"; static const String projectsRoute = "/dashboard"; @@ -28,6 +31,8 @@ class DashboardScreen extends StatefulWidget { } class _DashboardScreenState extends State with UIMixin { + final DashboardController dashboardController = + Get.put(DashboardController()); bool hasMpin = true; @override @@ -53,6 +58,9 @@ class _DashboardScreenState extends State with UIMixin { children: [ MySpacing.height(12), _buildDashboardStats(), + MySpacing.height(24), + AttendanceDashboardChart(), + MySpacing.height(300), if (!hasMpin) ...[ MyCard( @@ -63,7 +71,7 @@ class _DashboardScreenState extends State with UIMixin { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.warning_amber_rounded, + const Icon(Icons.warning_amber_rounded, color: Colors.redAccent, size: 28), MySpacing.width(12), Expanded( @@ -93,7 +101,7 @@ class _DashboardScreenState extends State with UIMixin { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.lock_outline, + const Icon(Icons.lock_outline, size: 18, color: Colors.white), MySpacing.width(8), MyText.bodyMedium( @@ -133,8 +141,21 @@ class _DashboardScreenState extends State with UIMixin { return GetBuilder( id: 'dashboard_controller', builder: (controller) { + final bool isLoading = controller.isLoading.value; 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( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -184,6 +205,33 @@ class _DashboardScreenState extends State 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) { return Opacity( opacity: isEnabled ? 1.0 : 0.4, diff --git a/lib/view/employees/employees_screen.dart b/lib/view/employees/employees_screen.dart index 6cc3960..37bbc28 100644 --- a/lib/view/employees/employees_screen.dart +++ b/lib/view/employees/employees_screen.dart @@ -14,7 +14,7 @@ import 'package:marco/controller/dashboard/employees_screen_controller.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/model/employees/employee_detail_bottom_sheet.dart'; import 'package:marco/controller/project_controller.dart'; - +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; class EmployeesScreen extends StatefulWidget { const EmployeesScreen({super.key}); @@ -292,7 +292,7 @@ class _EmployeesScreenState extends State with UIMixin { final isLoading = employeeScreenController.isLoading.value; final employees = employeeScreenController.employees; if (isLoading) { - return const Center(child: CircularProgressIndicator()); + return SkeletonLoaders.employeeListSkeletonLoader(); } if (employees.isEmpty) { return Padding( diff --git a/lib/view/layouts/layout.dart b/lib/view/layouts/layout.dart index eca8b05..6083c23 100644 --- a/lib/view/layouts/layout.dart +++ b/lib/view/layouts/layout.dart @@ -46,45 +46,52 @@ class _LayoutState extends State { endDrawer: UserProfileBar(), floatingActionButton: widget.floatingActionButton, body: SafeArea( - child: Stack( - children: [ - Column( - children: [ - _buildHeader(context, isMobile), - Expanded( - child: SingleChildScrollView( - key: controller.scrollKey, - padding: EdgeInsets.symmetric( - horizontal: 0, vertical: isMobile ? 16 : 32), - child: widget.child, - ), - ), - ], - ), - // Overlay project list below header - Obx(() { - if (!projectController.isProjectSelectionExpanded.value) { - return const SizedBox.shrink(); - } - return Positioned( - top: 95, // Adjust based on header card height - left: 16, - right: 16, - child: Material( - elevation: 4, - borderRadius: BorderRadius.circular(12), - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + if (projectController.isProjectSelectionExpanded.value) { + projectController.isProjectSelectionExpanded.value = false; + } + }, + child: Stack( + children: [ + Column( + children: [ + _buildHeader(context, isMobile), + Expanded( + child: SingleChildScrollView( + key: controller.scrollKey, + padding: EdgeInsets.symmetric( + horizontal: 0, vertical: isMobile ? 16 : 32), + child: widget.child, ), - padding: const EdgeInsets.all(10), - child: _buildProjectList(context, isMobile), ), - ), - ); - }), - ], + ], + ), + Obx(() { + if (!projectController.isProjectSelectionExpanded.value) { + return const SizedBox.shrink(); + } + return Positioned( + top: 95, + left: 16, + right: 16, + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(12), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(10), + child: _buildProjectList(context, isMobile), + ), + ), + ); + }), + ], + ), ), ), ); @@ -94,6 +101,12 @@ class _LayoutState extends State { return Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Obx(() { + final isLoading = projectController.isLoading.value; + + if (isLoading) { + return _buildLoadingSkeleton(); + } + final isExpanded = projectController.isProjectSelectionExpanded.value; final selectedProjectId = projectController.selectedProjectId?.value; final selectedProject = projectController.projects.firstWhereOrNull( @@ -212,8 +225,6 @@ class _LayoutState extends State { ], ), ), - - // Expanded Project List inside card — only show if projects exist if (isExpanded && hasProjects) Positioned( top: 70, @@ -232,6 +243,56 @@ class _LayoutState extends State { ); } + 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) { return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/view/taskPlaning/daily_progress.dart b/lib/view/taskPlaning/daily_progress.dart index 8247041..9d81018 100644 --- a/lib/view/taskPlaning/daily_progress.dart +++ b/lib/view/taskPlaning/daily_progress.dart @@ -12,9 +12,9 @@ import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/dashboard/daily_task_controller.dart'; import 'package:marco/model/dailyTaskPlaning/daily_progress_report_filter.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/model/dailyTaskPlaning/task_action_buttons.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; class DailyProgressReportScreen extends StatefulWidget { const DailyProgressReportScreen({super.key}); @@ -72,14 +72,14 @@ class _DailyProgressReportScreenState extends State @override Widget build(BuildContext context) { return Scaffold( - appBar: PreferredSize( + appBar: PreferredSize( preferredSize: const Size.fromHeight(80), child: AppBar( backgroundColor: const Color(0xFFF5F5F5), elevation: 0.5, foregroundColor: Colors.black, titleSpacing: 0, - centerTitle: false, + centerTitle: false, leading: Padding( padding: const EdgeInsets.only(top: 15.0), // Aligns with title child: IconButton( @@ -91,7 +91,7 @@ class _DailyProgressReportScreenState extends State ), ), title: Padding( - padding: const EdgeInsets.only(top: 15.0), + padding: const EdgeInsets.only(top: 15.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, @@ -298,7 +298,7 @@ class _DailyProgressReportScreenState extends State final groupedTasks = dailyTaskController.groupedDailyTasks; if (isLoading) { - return const Center(child: CircularProgressIndicator()); + return SkeletonLoaders.dailyProgressReportSkeletonLoader(); } if (groupedTasks.isEmpty) { @@ -369,6 +369,8 @@ class _DailyProgressReportScreenState extends State final activityName = task.workItem?.activityMaster?.activityName ?? 'N/A'; + final activityId = task.workItem?.activityMaster?.id; + final workAreaId = task.workItem?.workArea?.id; final location = [ task.workItem?.workArea?.floor?.building?.name, task.workItem?.workArea?.floor?.floorName, @@ -380,7 +382,7 @@ class _DailyProgressReportScreenState extends State final progress = (planned != 0) ? (completed / planned).clamp(0.0, 1.0) : 0.0; - + final parentTaskID = task.id; return Column( children: [ Padding( @@ -459,197 +461,32 @@ class _DailyProgressReportScreenState extends State mainAxisAlignment: MainAxisAlignment.end, children: [ if (task.reportedDate == null || - task.reportedDate.toString().isEmpty) - 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: (BuildContext ctx) => - Padding( - padding: MediaQuery.of(ctx) - .viewInsets, - child: ReportTaskBottomSheet( - taskData: taskData, - onReportSuccess: () { - _refreshData(); - }, - ), - ), - ); - }, + task.reportedDate + .toString() + .isEmpty) ...[ + TaskActionButtons.reportButton( + context: context, + task: task, + completed: completed.toInt(), + refreshCallback: _refreshData, ), - const SizedBox(width: 8), - 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), + const SizedBox(width: 8), + ] else if (task.approvedBy == null) ...[ + TaskActionButtons.reportActionButton( + context: context, + task: task, + parentTaskID: parentTaskID, + workAreaId: workAreaId.toString(), + activityId: activityId.toString(), + completed: completed.toInt(), + refreshCallback: _refreshData, ), - 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, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => - CommentTaskBottomSheet( - taskData: taskData, - onCommentSuccess: () { - _refreshData(); - Navigator.of(context).pop(); - }, - ), - ); - }, + const SizedBox(width: 8), + ], + TaskActionButtons.commentButton( + context: context, + task: task, + refreshCallback: _refreshData, ), ], ) diff --git a/lib/view/taskPlaning/daily_task_planing.dart b/lib/view/taskPlaning/daily_task_planing.dart index eb37766..b28fa77 100644 --- a/lib/view/taskPlaning/daily_task_planing.dart +++ b/lib/view/taskPlaning/daily_task_planing.dart @@ -11,6 +11,7 @@ import 'package:marco/controller/task_planing/daily_task_planing_controller.dart import 'package:marco/controller/project_controller.dart'; import 'package:percent_indicator/percent_indicator.dart'; import 'package:marco/model/dailyTaskPlaning/assign_task_bottom_sheet .dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; class DailyTaskPlaningScreen extends StatefulWidget { DailyTaskPlaningScreen({super.key}); @@ -170,7 +171,7 @@ class _DailyTaskPlaningScreenState extends State final dailyTasks = dailyTaskPlaningController.dailyTasks; if (isLoading) { - return Center(child: CircularProgressIndicator()); + return SkeletonLoaders.dailyProgressPlanningSkeletonCollapsedOnly(); } if (dailyTasks.isEmpty) { @@ -279,10 +280,10 @@ class _DailyTaskPlaningScreenState extends State floorExpansionState[floorWorkAreaKey] ?? false; final totalPlanned = area.workItems .map((wi) => wi.workItem.plannedWork ?? 0) - .fold(0, (prev, curr) => prev + curr); + .fold(0, (prev, curr) => prev + curr); final totalCompleted = area.workItems .map((wi) => wi.workItem.completedWork ?? 0) - .fold(0, (prev, curr) => prev + curr); + .fold(0, (prev, curr) => prev + curr); final totalProgress = totalPlanned == 0 ? 0.0 : (totalCompleted / totalPlanned).clamp(0.0, 1.0); @@ -429,7 +430,7 @@ class _DailyTaskPlaningScreenState extends State onPressed: () { final pendingTask = (planned - completed) - .clamp(0, planned); + .clamp(0, planned).toInt(); showModalBottomSheet( context: context, diff --git a/pubspec.yaml b/pubspec.yaml index 004626a..808debf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -98,8 +98,6 @@ flutter: - assets/avatar/ - assets/coin/ - assets/country/ - - assets/data/ - - assets/dummy/ - assets/lang/ - assets/logo/ - assets/logo/loading_logo.png