480 lines
14 KiB
Dart
480 lines
14 KiB
Dart
import 'package:get/get.dart';
|
|
import 'package:on_field_work/helpers/services/api_service.dart';
|
|
import 'package:on_field_work/model/service_project/service_projects_details_model.dart';
|
|
import 'package:on_field_work/model/service_project/job_list_model.dart';
|
|
import 'package:on_field_work/model/service_project/service_project_job_detail_model.dart';
|
|
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 --------------------
|
|
var projectId = ''.obs;
|
|
var projectDetail = Rxn<ProjectDetail>();
|
|
var jobList = <JobEntity>[].obs;
|
|
var jobDetail = Rxn<JobDetailsResponse>();
|
|
var showArchivedJobs = false.obs; // true = archived, false = active
|
|
|
|
// Loading states
|
|
var isLoading = false.obs;
|
|
var isJobLoading = false.obs;
|
|
var isJobDetailLoading = false.obs;
|
|
|
|
// Error messages
|
|
var errorMessage = ''.obs;
|
|
var jobErrorMessage = ''.obs;
|
|
var jobDetailErrorMessage = ''.obs;
|
|
final ImagePicker picker = ImagePicker();
|
|
var isProcessingAttachment = false.obs;
|
|
|
|
// Pagination
|
|
var pageNumber = 1;
|
|
final int pageSize = 20;
|
|
var hasMoreJobs = true.obs;
|
|
|
|
var isTagging = false.obs;
|
|
var attendanceMessage = ''.obs;
|
|
var attendanceLog = Rxn<JobAttendanceResponse>();
|
|
var teamList = <ServiceProjectAllocation>[].obs;
|
|
var isTeamLoading = false.obs;
|
|
var teamErrorMessage = ''.obs;
|
|
var filteredJobList = <JobEntity>[].obs;
|
|
// -------------------- Job Status --------------------
|
|
// With this:
|
|
var jobStatusList = <JobStatus>[].obs;
|
|
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() {
|
|
super.onInit();
|
|
fetchProjectJobs();
|
|
filteredJobList.value = jobList;
|
|
}
|
|
|
|
// -------------------- Project --------------------
|
|
void setProjectId(String id) {
|
|
if (projectId.value == id) return;
|
|
projectId.value = id;
|
|
|
|
// Reset pagination and list
|
|
pageNumber = 1;
|
|
hasMoreJobs.value = true;
|
|
jobList.clear();
|
|
filteredJobList.clear();
|
|
|
|
// Fetch project detail
|
|
fetchProjectDetail();
|
|
|
|
// Always fetch jobs for this project
|
|
fetchProjectJobs(refresh: true);
|
|
}
|
|
|
|
void updateJobSearch(String searchText) {
|
|
if (searchText.isEmpty) {
|
|
filteredJobList.value = jobList;
|
|
} else {
|
|
filteredJobList.value = jobList.where((job) {
|
|
final lowerSearch = searchText.toLowerCase();
|
|
return job.title.toLowerCase().contains(lowerSearch) ||
|
|
(job.description.toLowerCase().contains(lowerSearch)) ||
|
|
(job.tags?.any(
|
|
(tag) => tag.name.toLowerCase().contains(lowerSearch)) ??
|
|
false);
|
|
}).toList();
|
|
}
|
|
}
|
|
|
|
Future<void> fetchProjectTeams() async {
|
|
if (projectId.value.isEmpty) {
|
|
teamErrorMessage.value = "Invalid project ID";
|
|
return;
|
|
}
|
|
|
|
isTeamLoading.value = true;
|
|
teamErrorMessage.value = '';
|
|
|
|
try {
|
|
final result = await ApiService.getServiceProjectAllocationList(
|
|
projectId: projectId.value,
|
|
isActive: true,
|
|
);
|
|
|
|
if (result != null) {
|
|
teamList.value = result;
|
|
} else {
|
|
teamErrorMessage.value = "No teams found";
|
|
}
|
|
} catch (e) {
|
|
teamErrorMessage.value = "Error fetching teams: $e";
|
|
} finally {
|
|
isTeamLoading.value = false;
|
|
}
|
|
}
|
|
|
|
Future<void> fetchJobStatus({required String statusId}) async {
|
|
if (projectId.value.isEmpty) {
|
|
jobStatusErrorMessage.value = "Invalid project ID";
|
|
return;
|
|
}
|
|
|
|
isJobStatusLoading.value = true;
|
|
jobStatusErrorMessage.value = '';
|
|
|
|
try {
|
|
final statuses = await ApiService.getMasterJobStatus(
|
|
projectId: projectId.value,
|
|
statusId: statusId,
|
|
);
|
|
|
|
if (statuses != null && statuses.isNotEmpty) {
|
|
jobStatusList.value = statuses;
|
|
|
|
// Keep previously selected if exists, else pick first
|
|
selectedJobStatus.value = statuses.firstWhere(
|
|
(status) => status.id == selectedJobStatus.value?.id,
|
|
orElse: () => statuses.first,
|
|
);
|
|
|
|
print("Job Status List: ${jobStatusList.map((e) => e.name).toList()}");
|
|
} else {
|
|
jobStatusErrorMessage.value = "No job statuses found";
|
|
}
|
|
} catch (e) {
|
|
jobStatusErrorMessage.value = "Error fetching job status: $e";
|
|
} finally {
|
|
isJobStatusLoading.value = false;
|
|
}
|
|
}
|
|
|
|
Future<void> fetchProjectDetail() async {
|
|
if (projectId.value.isEmpty) {
|
|
errorMessage.value = "Invalid project ID";
|
|
return;
|
|
}
|
|
|
|
isLoading.value = true;
|
|
errorMessage.value = '';
|
|
|
|
try {
|
|
final result =
|
|
await ApiService.getServiceProjectDetailApi(projectId.value);
|
|
|
|
if (result != null && result.data != null) {
|
|
projectDetail.value = result.data!;
|
|
} else {
|
|
errorMessage.value =
|
|
result?.message ?? "Failed to fetch project details";
|
|
}
|
|
} catch (e) {
|
|
errorMessage.value = "Error: $e";
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
}
|
|
|
|
Future<void> fetchJobAttendanceLog(String attendanceId) async {
|
|
if (attendanceId.isEmpty) {
|
|
attendanceMessage.value = "Invalid attendance ID";
|
|
return;
|
|
}
|
|
|
|
isJobDetailLoading.value = true;
|
|
attendanceMessage.value = '';
|
|
|
|
try {
|
|
final result =
|
|
await ApiService.getJobAttendanceLog(attendanceId: attendanceId);
|
|
|
|
if (result != null) {
|
|
attendanceLog.value = result;
|
|
} else {
|
|
attendanceMessage.value = "Attendance log not found or empty";
|
|
}
|
|
} catch (e) {
|
|
attendanceMessage.value = "Error fetching attendance log: $e";
|
|
} finally {
|
|
isJobDetailLoading.value = false;
|
|
}
|
|
}
|
|
|
|
// -------------------- Job List (modified to always load) --------------------
|
|
Future<void> fetchProjectJobs({bool refresh = false}) async {
|
|
if (projectId.value.isEmpty) return;
|
|
|
|
if (refresh) pageNumber = 1;
|
|
if (!hasMoreJobs.value && !refresh) return;
|
|
|
|
isJobLoading.value = true;
|
|
jobErrorMessage.value = '';
|
|
|
|
try {
|
|
final result = await ApiService.getServiceProjectJobListApi(
|
|
projectId: projectId.value,
|
|
pageNumber: pageNumber,
|
|
pageSize: pageSize,
|
|
isActive: true,
|
|
isArchive: showArchivedJobs.value,
|
|
);
|
|
|
|
if (result != null && result.data != null) {
|
|
final newJobs = result.data?.data ?? [];
|
|
|
|
if (refresh || pageNumber == 1) {
|
|
jobList.value = newJobs;
|
|
} else {
|
|
jobList.addAll(newJobs);
|
|
}
|
|
|
|
filteredJobList.value = jobList;
|
|
|
|
hasMoreJobs.value = newJobs.length == pageSize;
|
|
if (hasMoreJobs.value) pageNumber++;
|
|
} else {
|
|
jobErrorMessage.value = result?.message ?? "Failed to fetch jobs";
|
|
}
|
|
} catch (e) {
|
|
jobErrorMessage.value = "Error fetching jobs: $e";
|
|
} finally {
|
|
isJobLoading.value = false;
|
|
}
|
|
}
|
|
|
|
Future<void> fetchMoreJobs() async => fetchProjectJobs();
|
|
|
|
// -------------------- Manual Refresh --------------------
|
|
Future<void> refresh() async {
|
|
pageNumber = 1;
|
|
hasMoreJobs.value = true;
|
|
|
|
await Future.wait([
|
|
fetchProjectDetail(),
|
|
fetchProjectJobs(),
|
|
]);
|
|
}
|
|
|
|
// -------------------- Job Detail --------------------
|
|
Future<void> fetchJobDetail(String jobId) async {
|
|
if (jobId.isEmpty) {
|
|
jobDetailErrorMessage.value = "Invalid job ID";
|
|
return;
|
|
}
|
|
|
|
isJobDetailLoading.value = true;
|
|
jobDetailErrorMessage.value = '';
|
|
|
|
try {
|
|
final result = await ApiService.getServiceProjectJobDetailApi(jobId);
|
|
if (result != null) {
|
|
jobDetail.value = result;
|
|
} else {
|
|
jobDetailErrorMessage.value = "Failed to fetch job details";
|
|
}
|
|
} catch (e) {
|
|
jobDetailErrorMessage.value = "Error fetching job details: $e";
|
|
} finally {
|
|
isJobDetailLoading.value = false;
|
|
}
|
|
}
|
|
|
|
Future<Position?> _getCurrentLocation() async {
|
|
try {
|
|
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
|
if (!serviceEnabled) {
|
|
attendanceMessage.value = "Location services are disabled.";
|
|
return null;
|
|
}
|
|
|
|
LocationPermission permission = await Geolocator.checkPermission();
|
|
if (permission == LocationPermission.denied) {
|
|
permission = await Geolocator.requestPermission();
|
|
if (permission == LocationPermission.denied) {
|
|
attendanceMessage.value = "Location permission denied";
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (permission == LocationPermission.deniedForever) {
|
|
attendanceMessage.value =
|
|
"Location permission permanently denied. Enable it from settings.";
|
|
return null;
|
|
}
|
|
|
|
return await Geolocator.getCurrentPosition(
|
|
desiredAccuracy: LocationAccuracy.high);
|
|
} catch (e) {
|
|
attendanceMessage.value = "Failed to get location: $e";
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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,
|
|
required int action,
|
|
String comment = "Updated via app",
|
|
File? attachment,
|
|
}) async {
|
|
if (jobId.isEmpty) return;
|
|
|
|
isTagging.value = true;
|
|
attendanceMessage.value = '';
|
|
|
|
try {
|
|
final position = await _getCurrentLocation();
|
|
if (position == null) {
|
|
isTagging.value = false;
|
|
return;
|
|
}
|
|
|
|
Map<String, dynamic>? attachmentPayload;
|
|
|
|
if (attachment != null) {
|
|
final bytes = await attachment.readAsBytes();
|
|
final base64Data = base64Encode(bytes);
|
|
final mimeType =
|
|
lookupMimeType(attachment.path) ?? 'application/octet-stream';
|
|
attachmentPayload = {
|
|
"documentId": jobId,
|
|
"fileName": attachment.path.split('/').last,
|
|
"base64Data": base64Data,
|
|
"contentType": mimeType,
|
|
"fileSize": bytes.length,
|
|
"description": "Attached via app",
|
|
"isActive": true,
|
|
};
|
|
}
|
|
|
|
final payload = {
|
|
"jobTcketId": jobId,
|
|
"action": action,
|
|
"latitude": position.latitude.toString(),
|
|
"longitude": position.longitude.toString(),
|
|
"comment": comment,
|
|
"attachment": attachmentPayload,
|
|
};
|
|
|
|
final success = await ApiService.updateServiceProjectJobAttendance(
|
|
payload: payload,
|
|
);
|
|
|
|
if (success) {
|
|
attendanceMessage.value =
|
|
action == 0 ? "Tagged In successfully" : "Tagged Out successfully";
|
|
await fetchJobDetail(jobId);
|
|
} else {
|
|
attendanceMessage.value = "Failed to update attendance";
|
|
}
|
|
} catch (e) {
|
|
attendanceMessage.value = "Error updating attendance: $e";
|
|
} finally {
|
|
isTagging.value = false;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// 🔥 AUTO REFRESH JOB LIST AFTER ADDING A JOB
|
|
// ------------------------------------------------------------
|
|
Future<void> refreshJobsAfterAdd() async {
|
|
pageNumber = 1;
|
|
hasMoreJobs.value = true;
|
|
await fetchProjectJobs();
|
|
}
|
|
}
|