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/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index dba0bb0..44b5310 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -24,4 +24,6 @@ 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"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 0b51c3a..b48f255 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -45,7 +45,8 @@ class ApiService { return null; } - static dynamic _parseResponseForAllData(http.Response response, {String label = ''}) { + static dynamic _parseResponseForAllData(http.Response response, + {String label = ''}) { _log("$label Response: ${response.body}"); try { final json = jsonDecode(response.body); @@ -67,15 +68,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 +110,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; @@ -119,14 +124,18 @@ class ApiService { // === 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.getProjects).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 +144,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 +208,8 @@ class ApiService { } } - final response = await _postRequest(ApiEndpoints.uploadAttendanceImage, body); + final response = + await _postRequest(ApiEndpoints.uploadAttendanceImage, body); if (response == null) return false; final json = jsonDecode(response.body); @@ -213,17 +228,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, @@ -245,10 +265,13 @@ class ApiService { 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 +284,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({ @@ -313,11 +338,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 +360,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 +373,30 @@ class ApiService { _log("Failed to assign daily task: ${json['message'] ?? 'Unknown error'}"); return false; } + + static Future?> getWorkStatus() async => + _getRequest(ApiEndpoints.getWorkStatus).then((res) => res != null + ? _parseResponseForAllData(res, label: 'Work Status') + : 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; + } } 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..196434c --- /dev/null +++ b/lib/model/dailyTaskPlaning/create_task_botom_sheet.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +void showManageTaskBottomSheet({ + required String building, + required String floor, + required String workArea, + required String activity, + required String plannedWork, + required String completedWork, + required String unit, + required Function(String) onCategoryChanged, + required VoidCallback onSubmit, +}) { + final List categories = ["Fresh Work", "Repair", "Demo"]; + String selectedCategory = 'Fresh Work'; + + 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 SafeArea( + child: Container( + constraints: const BoxConstraints(maxHeight: 0.95 * 800), + padding: EdgeInsets.fromLTRB(horizontalPadding, 12, horizontalPadding, 24), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Draggable handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade400, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + _infoRowWithIcon(Icons.location_city, "Selected Building", building), + _infoRowWithIcon(Icons.apartment, "Selected Floor", floor), + _infoRowWithIcon(Icons.workspaces, "Selected Work Area", workArea), + _infoRowWithIcon(Icons.list_alt, "Selected Activity", activity), + + const SizedBox(height: 12), + Row( + children: [ + Icon(Icons.category_outlined, color: Colors.grey[700], size: 18), + const SizedBox(width: 8), + MyText.bodyMedium("Selected Work Category", fontWeight: 600), + ], + ), + const SizedBox(height: 6), + + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: PopupMenuButton( + padding: EdgeInsets.zero, + onSelected: (val) { + setState(() { + selectedCategory = val; + }); + onCategoryChanged(val); + }, + itemBuilder: (context) => categories + .map((e) => PopupMenuItem(value: e, child: Text(e))) + .toList(), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + selectedCategory, + style: TextStyle(fontSize: 14, color: Colors.black87), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ), + + const SizedBox(height: 12), + _infoRowWithIcon(Icons.edit_calendar, "Planned Work", plannedWork), + _infoRowWithIcon(Icons.check_circle_outline, "Completed Work", completedWork), + _infoRowWithIcon(Icons.straighten, "Unit", unit), + + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: onSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blueAccent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: const Text( + "Submit", + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, + ), + isScrollControlled: true, + ); +} + +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/report_action_bottom_sheet.dart b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart new file mode 100644 index 0000000..f4669de --- /dev/null +++ b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart @@ -0,0 +1,1023 @@ +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; + + const ReportActionBottomSheet({ + super.key, + required this.taskData, + this.onCommentSuccess, + }); + + @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.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)); // Ensure animation completes + showManageTaskBottomSheet( + building: widget.taskData['building'] ?? '', + floor: widget.taskData['floor'] ?? '', + workArea: widget.taskData['location'] ?? '', + activity: widget.taskData['activity'] ?? '', + plannedWork: + widget.taskData['plannedWork'] ?? '', + completedWork: + widget.taskData['completedWork'] ?? '', + unit: widget.taskData['unit'] ?? '', + onCategoryChanged: (category) { + debugPrint( + "Category changed to: $category"); + }, + onSubmit: () { + Navigator.of(context) + .pop(); // Close second sheet + }, + ); + } + + // Optional callback after comment success + widget.onCommentSuccess?.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..5c188bd --- /dev/null +++ b/lib/model/dailyTaskPlaning/task_action_buttons.dart @@ -0,0 +1,195 @@ +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); + 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, + }) { + 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, + ), + ), + ); + }, + ); + } + + 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/view/taskPlaning/daily_progress.dart b/lib/view/taskPlaning/daily_progress.dart index 8247041..d60daf2 100644 --- a/lib/view/taskPlaning/daily_progress.dart +++ b/lib/view/taskPlaning/daily_progress.dart @@ -12,9 +12,8 @@ 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'; class DailyProgressReportScreen extends StatefulWidget { const DailyProgressReportScreen({super.key}); @@ -72,14 +71,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 +90,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, @@ -459,197 +458,29 @@ 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, + 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 ...[ + TaskActionButtons.reportActionButton( + context: context, + task: task, + completed: completed, + 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, ), ], )