Implement job comments feature: add comment widget, API endpoints, and controller methods for fetching and posting comments

This commit is contained in:
Vaibhav Surve 2025-12-01 14:38:58 +05:30
parent d4d678d98a
commit 012d40cd57
7 changed files with 961 additions and 2 deletions

View File

@ -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<JobStatus?>(null);
var isJobStatusLoading = false.obs;
var jobStatusErrorMessage = ''.obs;
// -------------------- Job Comments --------------------
var jobComments = <CommentItem>[].obs;
var isCommentsLoading = false.obs;
var commentsErrorMessage = ''.obs;
// -------------------- Lifecycle --------------------
@override
void onInit() {
@ -313,6 +319,91 @@ class ServiceProjectDetailsController extends GetxController {
}
}
Future<void> 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<bool> addJobComment({
required String jobId,
required String comment,
List<File>? files,
}) async {
try {
List<Map<String, dynamic>> 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<void> updateJobAttendance({
required String jobId,

View File

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

View File

@ -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<JobCommentResponse?> 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<bool> addJobComment({
required String jobTicketId,
required String comment,
List<Map<String, dynamic>> 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<List<JobStatus>?> getMasterJobStatus({
required String statusId,
required String projectId,

View File

@ -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<AddCommentWidget> createState() => _AddCommentWidgetState();
}
class _AddCommentWidgetState extends State<AddCommentWidget> {
final TextEditingController _controller = TextEditingController();
final List<File> _selectedFiles = [];
final ServiceProjectDetailsController controller =
Get.find<ServiceProjectDetailsController>();
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<void> _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<String>().map((path) => File(path)));
});
}
} catch (e) {
Get.snackbar("Error", "Failed to pick files: $e");
}
}
// --- PICK IMAGE FROM CAMERA ---
Future<void> _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<void> _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(),
],
)),
],
);
}
}

View File

@ -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<String, dynamic> 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<String, dynamic> 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<CommentItem>? data;
JobCommentData({
this.currentPage,
this.totalPages,
this.totalEntities,
this.data,
});
factory JobCommentData.fromJson(Map<String, dynamic> json) {
return JobCommentData(
currentPage: json['currentPage'] as int?,
totalPages: json['totalPages'] as int?,
totalEntities: json['totalEntities'] as int?,
data: json['data'] != null
? List<CommentItem>.from(
(json['data'] as List).map((x) => CommentItem.fromJson(x)))
: null,
);
}
Map<String, dynamic> 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<Attachment>? attachments;
CommentItem({
this.id,
this.jobTicket,
this.comment,
this.isActive,
this.createdAt,
this.createdBy,
this.updatedAt,
this.updatedBy,
this.attachments,
});
factory CommentItem.fromJson(Map<String, dynamic> 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<Attachment>.from(
(json['attachments'] as List).map((x) => Attachment.fromJson(x)))
: null,
);
}
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() => {
'id': id,
'batchId': batchId,
'fileName': fileName,
'preSignedUrl': preSignedUrl,
'thumbPreSignedUrl': thumbPreSignedUrl,
'fileSize': fileSize,
'contentType': contentType,
'uploadedAt': uploadedAt,
};
}

View File

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

View File

@ -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<JobDetailsScreen> 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),
],
),