Implement job comments feature: add comment widget, API endpoints, and controller methods for fetching and posting comments
This commit is contained in:
parent
d4d678d98a
commit
012d40cd57
@ -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,
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
510
lib/helpers/widgets/serviceProject/add_comment_widget.dart
Normal file
510
lib/helpers/widgets/serviceProject/add_comment_widget.dart
Normal 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(),
|
||||
],
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
253
lib/model/service_project/job_comments.dart
Normal file
253
lib/model/service_project/job_comments.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user