diff --git a/lib/controller/service_project/service_project_details_screen_controller.dart b/lib/controller/service_project/service_project_details_screen_controller.dart index a3559e7..a916d22 100644 --- a/lib/controller/service_project/service_project_details_screen_controller.dart +++ b/lib/controller/service_project/service_project_details_screen_controller.dart @@ -7,10 +7,11 @@ import 'package:geolocator/geolocator.dart'; import 'package:on_field_work/model/service_project/job_attendance_logs_model.dart'; import 'package:on_field_work/model/service_project/job_allocation_model.dart'; import 'package:on_field_work/model/service_project/job_status_response.dart'; - +import 'package:on_field_work/model/service_project/job_comments.dart'; import 'dart:convert'; import 'dart:io'; import 'package:mime/mime.dart'; +import 'package:image_picker/image_picker.dart'; class ServiceProjectDetailsController extends GetxController { // -------------------- Observables -------------------- @@ -29,6 +30,8 @@ class ServiceProjectDetailsController extends GetxController { var errorMessage = ''.obs; var jobErrorMessage = ''.obs; var jobDetailErrorMessage = ''.obs; + final ImagePicker picker = ImagePicker(); + var isProcessingAttachment = false.obs; // Pagination var pageNumber = 1; @@ -48,7 +51,10 @@ class ServiceProjectDetailsController extends GetxController { var selectedJobStatus = Rx(null); var isJobStatusLoading = false.obs; var jobStatusErrorMessage = ''.obs; - +// -------------------- Job Comments -------------------- + var jobComments = [].obs; + var isCommentsLoading = false.obs; + var commentsErrorMessage = ''.obs; // -------------------- Lifecycle -------------------- @override void onInit() { @@ -313,6 +319,91 @@ class ServiceProjectDetailsController extends GetxController { } } + Future fetchJobComments({bool refresh = false}) async { + if (jobDetail.value?.data?.id == null) { + commentsErrorMessage.value = "Invalid job ID"; + return; + } + + if (refresh) pageNumber = 1; + + isCommentsLoading.value = true; + commentsErrorMessage.value = ''; + + try { + final response = await ApiService.getJobCommentList( + jobTicketId: jobDetail.value!.data!.id!, + pageNumber: pageNumber, + pageSize: pageSize, + ); + + if (response != null && response.data != null) { + final newComments = response.data?.data ?? []; + + if (refresh || pageNumber == 1) { + jobComments.value = newComments; + } else { + jobComments.addAll(newComments); + } + + hasMoreJobs.value = + (response.data?.totalEntities ?? 0) > (pageNumber * pageSize); + if (hasMoreJobs.value) pageNumber++; + } else { + commentsErrorMessage.value = + response?.message ?? "Failed to fetch comments"; + } + } catch (e) { + commentsErrorMessage.value = "Error fetching comments: $e"; + } finally { + isCommentsLoading.value = false; + } + } + + Future addJobComment({ + required String jobId, + required String comment, + List? files, + }) async { + try { + List> attachments = []; + + if (files != null && files.isNotEmpty) { + for (final file in files) { + final bytes = await file.readAsBytes(); + final base64Data = base64Encode(bytes); + final mimeType = + lookupMimeType(file.path) ?? "application/octet-stream"; + + attachments.add({ + "fileName": file.path.split('/').last, + "base64Data": base64Data, + "contentType": mimeType, + "fileSize": bytes.length, + "description": "", + "isActive": true, + }); + } + } + + final success = await ApiService.addJobComment( + jobTicketId: jobId, + comment: comment, + attachments: attachments, + ); + + if (success) { + await fetchJobDetail(jobId); + refresh(); + } + + return success; + } catch (e) { + print("Error adding comment: $e"); + return false; + } + } + /// Tag In / Tag Out for a job with proper payload Future updateJobAttendance({ required String jobId, diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index f7f5759..6a18c23 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -154,4 +154,8 @@ class ApiEndpoints { static const String getServiceProjectBranches = "/serviceproject/branch/list"; static const String getMasterJobStatus = "/Master/job-status/list"; + + static const String addJobComment = "/ServiceProject/job/add/comment"; + + static const String getJobCommentList = "/ServiceProject/job/comment/list"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 2a63441..37f5b83 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -41,6 +41,7 @@ import 'package:on_field_work/model/service_project/job_attendance_logs_model.da import 'package:on_field_work/model/service_project/job_allocation_model.dart'; import 'package:on_field_work/model/service_project/service_project_branches_model.dart'; import 'package:on_field_work/model/service_project/job_status_response.dart'; +import 'package:on_field_work/model/service_project/job_comments.dart'; class ApiService { static const bool enableLogs = true; @@ -312,6 +313,89 @@ class ApiService { } } + static Future getJobCommentList({ + required String jobTicketId, + int pageNumber = 1, + int pageSize = 20, + }) async { + final queryParams = { + 'jobTicketId': jobTicketId, + 'pageNumber': pageNumber.toString(), + 'pageSize': pageSize.toString(), + }; + + try { + final response = await _getRequest( + ApiEndpoints.getJobCommentList, + queryParams: queryParams, + ); + + if (response == null) { + _log("getJobCommentList: No response from server", + level: LogLevel.error); + return null; + } + + final parsedJson = + _parseResponseForAllData(response, label: "JobCommentList"); + if (parsedJson == null) return null; + + return JobCommentResponse.fromJson(parsedJson); + } catch (e, stack) { + _log("Exception in getJobCommentList: $e\n$stack", level: LogLevel.error); + return null; + } + } + + static Future addJobComment({ + required String jobTicketId, + required String comment, + List> attachments = const [], + }) async { + final body = { + "jobTicketId": jobTicketId, + "comment": comment, + "attachments": attachments, + }; + + try { + final response = await _postRequest( + ApiEndpoints.addJobComment, + body, + ); + + if (response == null) { + _log("addJobComment: No response from server", level: LogLevel.error); + return false; + } + + // Handle 201 Created as success manually + if (response.statusCode == 201) { + _log("AddJobComment: Comment added successfully (201).", + level: LogLevel.info); + return true; + } + + // Otherwise fallback to existing _parseResponse + final parsed = _parseResponse(response, label: "AddJobComment"); + + if (parsed != null && parsed['success'] == true) { + _log("AddJobComment: Comment added successfully.", + level: LogLevel.info); + return true; + } else { + _log( + "AddJobComment failed: ${parsed?['message'] ?? 'Unknown error'}", + level: LogLevel.error, + ); + return false; + } + } catch (e, stack) { + _log("Exception in addJobComment: $e\n$stack", level: LogLevel.error); + return false; + } + } + static Future?> getMasterJobStatus({ required String statusId, required String projectId, diff --git a/lib/helpers/widgets/serviceProject/add_comment_widget.dart b/lib/helpers/widgets/serviceProject/add_comment_widget.dart new file mode 100644 index 0000000..f023cef --- /dev/null +++ b/lib/helpers/widgets/serviceProject/add_comment_widget.dart @@ -0,0 +1,510 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; + +import 'package:on_field_work/controller/service_project/service_project_details_screen_controller.dart'; +import 'package:on_field_work/helpers/widgets/my_spacing.dart'; +import 'package:on_field_work/helpers/widgets/my_text.dart'; +import 'package:on_field_work/model/service_project/job_comments.dart'; +import 'package:on_field_work/helpers/widgets/image_viewer_dialog.dart'; +import 'package:on_field_work/helpers/utils/date_time_utils.dart'; +import 'package:on_field_work/helpers/widgets/avatar.dart'; +import 'package:on_field_work/helpers/widgets/time_stamp_image_helper.dart'; + +class AddCommentWidget extends StatefulWidget { + final String jobId; + final String jobTicketId; + + const AddCommentWidget({ + super.key, + required this.jobId, + required this.jobTicketId, + }); + + @override + State createState() => _AddCommentWidgetState(); +} + +class _AddCommentWidgetState extends State { + final TextEditingController _controller = TextEditingController(); + final List _selectedFiles = []; + + final ServiceProjectDetailsController controller = + Get.find(); + + bool isSubmitting = false; + bool isProcessingAttachment = false; + + @override + void initState() { + super.initState(); + controller.fetchJobComments(refresh: true); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + // --- PICK MULTIPLE FILES --- + Future _pickFiles() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'], + allowMultiple: true, + ); + + if (result != null) { + setState(() { + _selectedFiles.addAll( + result.paths.whereType().map((path) => File(path))); + }); + } + } catch (e) { + Get.snackbar("Error", "Failed to pick files: $e"); + } + } + + // --- PICK IMAGE FROM CAMERA --- + Future _pickFromCamera() async { + try { + final pickedFile = + await controller.picker.pickImage(source: ImageSource.camera); + if (pickedFile != null) { + setState(() { + controller.isProcessingAttachment.value = + true; // optional: show loading + }); + + File imageFile = File(pickedFile.path); + + // Add timestamp to the captured image + File timestampedFile = await TimestampImageHelper.addTimestamp( + imageFile: imageFile, + ); + + setState(() { + _selectedFiles.add(timestampedFile); + }); + } + } catch (e) { + Get.snackbar("Camera error", "$e", + backgroundColor: Colors.red.shade200, colorText: Colors.white); + } finally { + setState(() { + controller.isProcessingAttachment.value = false; + }); + } + } + + // --- SUBMIT COMMENT --- + Future _submitComment() async { + if (_controller.text.trim().isEmpty && _selectedFiles.isEmpty) return; + + setState(() => isSubmitting = true); + + final success = await controller.addJobComment( + jobId: widget.jobId, + comment: _controller.text.trim(), + files: _selectedFiles, + ); + + setState(() => isSubmitting = false); + + if (success) { + _controller.clear(); + _selectedFiles.clear(); + FocusScope.of(context).unfocus(); + await controller.fetchJobComments(refresh: true); + } + } + + // --- HELPER: CHECK IF FILE IS IMAGE --- + bool _isImage(String? fileName) { + if (fileName == null) return false; + final ext = fileName.split('.').last.toLowerCase(); + return ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].contains(ext); + } + + // --- SELECTED FILES PREVIEW --- + // --- SELECTED FILES PREVIEW (styled like expense attachments) --- + Widget _buildSelectedFiles() { + if (_selectedFiles.isEmpty) return const SizedBox.shrink(); + + return SizedBox( + height: 44, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _selectedFiles.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final file = _selectedFiles[index]; + final fileName = file.path.split('/').last; + final isImage = _isImage(fileName); + + return GestureDetector( + onTap: isImage + ? () { + // Show image preview + Get.to(() => ImageViewerDialog( + imageSources: _selectedFiles.toList(), + initialIndex: _selectedFiles + .where((f) => _isImage(f.path.split('/').last)) + .toList() + .indexOf(file), + captions: _selectedFiles + .where((f) => _isImage(f.path.split('/').last)) + .map((f) => f.path.split('/').last) + .toList(), + )); + } + : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: isImage ? Colors.teal.shade50 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isImage ? Colors.teal.shade100 : Colors.grey.shade300, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isImage + ? Icons.insert_photo_outlined + : Icons.insert_drive_file_outlined, + size: 16, + color: + isImage ? Colors.teal.shade700 : Colors.grey.shade700, + ), + const SizedBox(width: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 100), + child: Text( + fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: isImage + ? Colors.teal.shade700 + : Colors.grey.shade700, + ), + ), + ), + const SizedBox(width: 6), + GestureDetector( + onTap: () => setState(() => _selectedFiles.removeAt(index)), + child: Icon( + Icons.close, + size: 14, + color: + isImage ? Colors.teal.shade700 : Colors.grey.shade700, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + // --- BUILD SINGLE COMMENT ITEM --- + Widget _buildCommentItem(CommentItem comment) { + final firstName = comment.createdBy?.firstName ?? ''; + final lastName = comment.createdBy?.lastName ?? ''; + final formattedDate = comment.createdAt != null + ? DateTimeUtils.convertUtcToLocal(comment.createdAt!, + format: 'dd MMM yyyy hh:mm a') + : "Just now"; + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar(firstName: firstName, lastName: lastName, size: 32), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + "$firstName $lastName".trim().isNotEmpty + ? "$firstName $lastName" + : "Unknown User", + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 14), + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6.0), + child: Text("•", + style: TextStyle(fontSize: 14, color: Colors.grey)), + ), + Icon(Icons.access_time, size: 14, color: Colors.grey[600]), + const SizedBox(width: 4), + Text(formattedDate, + style: + TextStyle(fontSize: 13, color: Colors.grey[600])), + ], + ), + const SizedBox(height: 4), + if (comment.comment != null && comment.comment!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Text( + comment.comment!, + style: + const TextStyle(fontSize: 14, color: Colors.black87), + ), + ), + if (comment.attachments != null && + comment.attachments!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: SizedBox( + height: 40, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: comment.attachments!.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final attachment = comment.attachments![index]; + final isImage = _isImage(attachment.fileName); + final imageAttachments = comment.attachments! + .where((a) => _isImage(a.fileName)) + .toList(); + final imageIndex = + imageAttachments.indexOf(attachment); + + return GestureDetector( + onTap: isImage + ? () { + Get.to(() => ImageViewerDialog( + imageSources: imageAttachments + .map((a) => a.preSignedUrl ?? "") + .toList(), + initialIndex: imageIndex, + captions: imageAttachments + .map((a) => a.fileName ?? "") + .toList(), + )); + } + : null, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: isImage + ? Colors.teal.shade50 + : Colors.purple.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isImage + ? Colors.teal.shade100 + : Colors.purple.shade100), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isImage + ? Icons.insert_photo_outlined + : Icons.insert_drive_file_outlined, + size: 16, + color: isImage + ? Colors.teal.shade700 + : Colors.purple.shade700, + ), + const SizedBox(width: 6), + ConstrainedBox( + constraints: + const BoxConstraints(maxWidth: 100), + child: Text( + attachment.fileName ?? "Attachment", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: isImage + ? Colors.teal.shade700 + : Colors.purple.shade700, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + // --- COMMENT LIST --- + Widget _buildCommentList() { + return Obx(() { + if (controller.isCommentsLoading.value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 32.0), + child: Center( + child: Column( + children: [ + const CircularProgressIndicator(strokeWidth: 3), + MySpacing.height(12), + MyText.bodyMedium("Loading comments...", + color: Colors.grey.shade600), + ], + ), + ), + ); + } + + if (controller.jobComments.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 32.0), + child: Center( + child: Column( + children: [ + Icon(Icons.chat_bubble_outline, + size: 40, color: Colors.grey.shade400), + MySpacing.height(8), + MyText.bodyMedium("No comments yet.", + color: Colors.grey.shade600), + MyText.bodySmall("Be the first to post a comment.", + color: Colors.grey.shade500), + ], + ), + ), + ); + } + + return Column( + children: controller.jobComments.map(_buildCommentItem).toList(), + ); + }); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(12), + TextField( + controller: _controller, + maxLines: 3, + decoration: InputDecoration( + hintText: "Type your comment here...", + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.blue, width: 2), + ), + ), + ), + MySpacing.height(10), + _buildSelectedFiles(), + MySpacing.height(10), + Row( + children: [ + // Attach file + IconButton( + onPressed: isSubmitting ? null : _pickFiles, + icon: const Icon(Icons.attach_file, size: 24, color: Colors.blue), + tooltip: "Attach File", + ), + + // Camera (icon-only) + Stack( + alignment: Alignment.center, + children: [ + IconButton( + onPressed: + isSubmitting || controller.isProcessingAttachment.value + ? null + : _pickFromCamera, + icon: const Icon(Icons.camera_alt, + size: 24, color: Colors.blue), + tooltip: "Camera", + ), + if (controller.isProcessingAttachment.value) + const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + + const Spacer(), + + // Submit button + ElevatedButton( + onPressed: isSubmitting ? null : _submitComment, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade700, + foregroundColor: Colors.white, + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + elevation: 2, + ), + child: isSubmitting + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Text("Post Comment"), + ), + ], + ), + MySpacing.height(30), + const Divider(height: 1, thickness: 0.5), + MySpacing.height(20), + Obx(() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium( + "Comments (${controller.jobComments.length})", + fontWeight: 700), + MySpacing.height(16), + _buildCommentList(), + ], + )), + ], + ); + } +} diff --git a/lib/model/service_project/job_comments.dart b/lib/model/service_project/job_comments.dart new file mode 100644 index 0000000..748fa9d --- /dev/null +++ b/lib/model/service_project/job_comments.dart @@ -0,0 +1,253 @@ +class JobCommentResponse { + final bool? success; + final String? message; + final JobCommentData? data; + final dynamic errors; + final int? statusCode; + final String? timestamp; + + JobCommentResponse({ + this.success, + this.message, + this.data, + this.errors, + this.statusCode, + this.timestamp, + }); + + factory JobCommentResponse.fromJson(Map json) { + return JobCommentResponse( + success: json['success'] as bool?, + message: json['message'] as String?, + data: json['data'] != null ? JobCommentData.fromJson(json['data']) : null, + errors: json['errors'], + statusCode: json['statusCode'] as int?, + timestamp: json['timestamp'] as String?, + ); + } + + Map toJson() => { + 'success': success, + 'message': message, + 'data': data?.toJson(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp, + }; +} + +class JobCommentData { + final int? currentPage; + final int? totalPages; + final int? totalEntities; + final List? data; + + JobCommentData({ + this.currentPage, + this.totalPages, + this.totalEntities, + this.data, + }); + + factory JobCommentData.fromJson(Map json) { + return JobCommentData( + currentPage: json['currentPage'] as int?, + totalPages: json['totalPages'] as int?, + totalEntities: json['totalEntities'] as int?, + data: json['data'] != null + ? List.from( + (json['data'] as List).map((x) => CommentItem.fromJson(x))) + : null, + ); + } + + Map toJson() => { + 'currentPage': currentPage, + 'totalPages': totalPages, + 'totalEntities': totalEntities, + 'data': data?.map((x) => x.toJson()).toList(), + }; +} + +class CommentItem { + final String? id; + final JobTicket? jobTicket; + final String? comment; + final bool? isActive; + final String? createdAt; + final User? createdBy; + final String? updatedAt; + final User? updatedBy; + final List? attachments; + + CommentItem({ + this.id, + this.jobTicket, + this.comment, + this.isActive, + this.createdAt, + this.createdBy, + this.updatedAt, + this.updatedBy, + this.attachments, + }); + + factory CommentItem.fromJson(Map json) { + return CommentItem( + id: json['id'] as String?, + jobTicket: json['jobTicket'] != null + ? JobTicket.fromJson(json['jobTicket']) + : null, + comment: json['comment'] as String?, + isActive: json['isActive'] as bool?, + createdAt: json['createdAt'] as String?, + createdBy: + json['createdBy'] != null ? User.fromJson(json['createdBy']) : null, + updatedAt: json['updatedAt'] as String?, + updatedBy: + json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null, + attachments: json['attachments'] != null + ? List.from( + (json['attachments'] as List).map((x) => Attachment.fromJson(x))) + : null, + ); + } + + Map toJson() => { + 'id': id, + 'jobTicket': jobTicket?.toJson(), + 'comment': comment, + 'isActive': isActive, + 'createdAt': createdAt, + 'createdBy': createdBy?.toJson(), + 'updatedAt': updatedAt, + 'updatedBy': updatedBy?.toJson(), + 'attachments': attachments?.map((x) => x.toJson()).toList(), + }; +} + +class JobTicket { + final String? id; + final String? title; + final String? description; + final String? jobTicketUId; + final String? statusName; + final bool? isArchive; + + JobTicket({ + this.id, + this.title, + this.description, + this.jobTicketUId, + this.statusName, + this.isArchive, + }); + + factory JobTicket.fromJson(Map json) { + return JobTicket( + id: json['id'] as String?, + title: json['title'] as String?, + description: json['description'] as String?, + jobTicketUId: json['jobTicketUId'] as String?, + statusName: json['statusName'] as String?, + isArchive: json['isArchive'] as bool?, + ); + } + + Map toJson() => { + 'id': id, + 'title': title, + 'description': description, + 'jobTicketUId': jobTicketUId, + 'statusName': statusName, + 'isArchive': isArchive, + }; +} + +class User { + final String? id; + final String? firstName; + final String? lastName; + final String? email; + final String? photo; + final String? jobRoleId; + final String? jobRoleName; + + User({ + this.id, + this.firstName, + this.lastName, + this.email, + this.photo, + this.jobRoleId, + this.jobRoleName, + }); + + factory User.fromJson(Map json) { + return User( + id: json['id'] as String?, + firstName: json['firstName'] as String?, + lastName: json['lastName'] as String?, + email: json['email'] as String?, + photo: json['photo'] as String?, + jobRoleId: json['jobRoleId'] as String?, + jobRoleName: json['jobRoleName'] as String?, + ); + } + + Map toJson() => { + 'id': id, + 'firstName': firstName, + 'lastName': lastName, + 'email': email, + 'photo': photo, + 'jobRoleId': jobRoleId, + 'jobRoleName': jobRoleName, + }; +} + +class Attachment { + final String? id; + final String? batchId; + final String? fileName; + final String? preSignedUrl; + final String? thumbPreSignedUrl; + final int? fileSize; + final String? contentType; + final String? uploadedAt; + + Attachment({ + this.id, + this.batchId, + this.fileName, + this.preSignedUrl, + this.thumbPreSignedUrl, + this.fileSize, + this.contentType, + this.uploadedAt, + }); + + factory Attachment.fromJson(Map json) { + return Attachment( + id: json['id'] as String?, + batchId: json['batchId'] as String?, + fileName: json['fileName'] as String?, + preSignedUrl: json['preSignedUrl'] as String?, + thumbPreSignedUrl: json['thumbPreSignedUrl'] as String?, + fileSize: json['fileSize'] as int?, + contentType: json['contentType'] as String?, + uploadedAt: json['uploadedAt'] as String?, + ); + } + + Map toJson() => { + 'id': id, + 'batchId': batchId, + 'fileName': fileName, + 'preSignedUrl': preSignedUrl, + 'thumbPreSignedUrl': thumbPreSignedUrl, + 'fileSize': fileSize, + 'contentType': contentType, + 'uploadedAt': uploadedAt, + }; +} diff --git a/lib/model/service_project/service_project_job_detail_model.dart b/lib/model/service_project/service_project_job_detail_model.dart index e7b48fc..32a4e2f 100644 --- a/lib/model/service_project/service_project_job_detail_model.dart +++ b/lib/model/service_project/service_project_job_detail_model.dart @@ -277,6 +277,7 @@ class UpdateLog { final Status? status; final Status? nextStatus; final String? comment; + final String? updatedAt; final User? updatedBy; UpdateLog({ @@ -284,6 +285,7 @@ class UpdateLog { this.status, this.nextStatus, this.comment, + this.updatedAt, this.updatedBy, }); @@ -297,6 +299,7 @@ class UpdateLog { ? Status.fromJson(json['nextStatus']) : null, comment: json['comment'] as String?, + updatedAt: json['updatedAt'] as String?, updatedBy: json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null, ); diff --git a/lib/view/service_project/service_project_job_detail_screen.dart b/lib/view/service_project/service_project_job_detail_screen.dart index 098d8e0..0d9aec2 100644 --- a/lib/view/service_project/service_project_job_detail_screen.dart +++ b/lib/view/service_project/service_project_job_detail_screen.dart @@ -19,6 +19,7 @@ import 'package:on_field_work/helpers/widgets/my_confirmation_dialog.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/model/service_project/job_status_response.dart'; +import 'package:on_field_work/helpers/widgets/serviceProject/add_comment_widget.dart'; class JobDetailsScreen extends StatefulWidget { final String jobId; @@ -1046,11 +1047,24 @@ class _JobDetailsScreenState extends State with UIMixin { titleIcon: Icons.label_outline, children: [_tagEditor()]), MySpacing.height(16), + if ((job.updateLogs?.isNotEmpty ?? false)) _buildSectionCard( title: "Update Logs", titleIcon: Icons.history, children: [JobTimeline(logs: job.updateLogs ?? [])]), + // ⭐ NEW CARD ADDED HERE + MySpacing.height(16), + if ((job.updateLogs?.isNotEmpty ?? false)) + _buildSectionCard( + title: "Comment Section", + titleIcon: Icons.comment_outlined, + children: [ + AddCommentWidget( + jobId: job.id ?? "", + jobTicketId: job.jobTicketUId ?? ""), + ]), + // ⭐ END NEW CARD MySpacing.height(80), ], ),