added tag in tag out api and code
This commit is contained in:
parent
605617695e
commit
919310644d
@ -3,6 +3,12 @@ import 'package:marco/helpers/services/api_service.dart';
|
|||||||
import 'package:marco/model/service_project/service_projects_details_model.dart';
|
import 'package:marco/model/service_project/service_projects_details_model.dart';
|
||||||
import 'package:marco/model/service_project/job_list_model.dart';
|
import 'package:marco/model/service_project/job_list_model.dart';
|
||||||
import 'package:marco/model/service_project/service_project_job_detail_model.dart';
|
import 'package:marco/model/service_project/service_project_job_detail_model.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:marco/model/service_project/job_attendance_logs_model.dart';
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:mime/mime.dart';
|
||||||
|
|
||||||
class ServiceProjectDetailsController extends GetxController {
|
class ServiceProjectDetailsController extends GetxController {
|
||||||
// -------------------- Observables --------------------
|
// -------------------- Observables --------------------
|
||||||
@ -25,6 +31,10 @@ class ServiceProjectDetailsController extends GetxController {
|
|||||||
var pageNumber = 1;
|
var pageNumber = 1;
|
||||||
final int pageSize = 20;
|
final int pageSize = 20;
|
||||||
var hasMoreJobs = true.obs;
|
var hasMoreJobs = true.obs;
|
||||||
|
// Inside ServiceProjectDetailsController
|
||||||
|
var isTagging = false.obs;
|
||||||
|
var attendanceMessage = ''.obs;
|
||||||
|
var attendanceLog = Rxn<JobAttendanceResponse>();
|
||||||
|
|
||||||
// -------------------- Lifecycle --------------------
|
// -------------------- Lifecycle --------------------
|
||||||
@override
|
@override
|
||||||
@ -68,6 +78,31 @@ class ServiceProjectDetailsController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add this method to your controller
|
||||||
|
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 = "Failed to fetch attendance log";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
attendanceMessage.value = "Error fetching attendance log: $e";
|
||||||
|
} finally {
|
||||||
|
isJobDetailLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------- Job List --------------------
|
// -------------------- Job List --------------------
|
||||||
Future<void> fetchProjectJobs({bool initialLoad = false}) async {
|
Future<void> fetchProjectJobs({bool initialLoad = false}) async {
|
||||||
if (projectId.value.isEmpty && !initialLoad) {
|
if (projectId.value.isEmpty && !initialLoad) {
|
||||||
@ -142,4 +177,99 @@ class ServiceProjectDetailsController extends GetxController {
|
|||||||
isJobDetailLoading.value = false;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -144,4 +144,6 @@ class ApiEndpoints {
|
|||||||
"/serviceproject/job/details";
|
"/serviceproject/job/details";
|
||||||
static const String editServiceProjectJob = "/serviceproject/job/edit";
|
static const String editServiceProjectJob = "/serviceproject/job/edit";
|
||||||
static const String createServiceProjectJob = "/serviceproject/job/create";
|
static const String createServiceProjectJob = "/serviceproject/job/create";
|
||||||
|
static const String serviceProjectUpateJobAttendance = "/serviceproject/job/attendance";
|
||||||
|
static const String serviceProjectUpateJobAttendanceLog = "/job/attendance/log";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,6 +37,7 @@ import 'package:marco/model/service_project/service_projects_list_model.dart';
|
|||||||
import 'package:marco/model/service_project/service_projects_details_model.dart';
|
import 'package:marco/model/service_project/service_projects_details_model.dart';
|
||||||
import 'package:marco/model/service_project/job_list_model.dart';
|
import 'package:marco/model/service_project/job_list_model.dart';
|
||||||
import 'package:marco/model/service_project/service_project_job_detail_model.dart';
|
import 'package:marco/model/service_project/service_project_job_detail_model.dart';
|
||||||
|
import 'package:marco/model/service_project/job_attendance_logs_model.dart';
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
static const bool enableLogs = true;
|
static const bool enableLogs = true;
|
||||||
@ -307,6 +308,70 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Service Project Module APIs
|
// Service Project Module APIs
|
||||||
|
/// Fetch Job Attendance Log by ID
|
||||||
|
static Future<JobAttendanceResponse?> getJobAttendanceLog({
|
||||||
|
required String attendanceId,
|
||||||
|
}) async {
|
||||||
|
final endpoint = "${ApiEndpoints.serviceProjectUpateJobAttendanceLog}/$attendanceId";
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _getRequest(endpoint);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
_log("getJobAttendanceLog: No response received.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final parsedJson =
|
||||||
|
_parseResponseForAllData(response, label: "JobAttendanceLog");
|
||||||
|
if (parsedJson == null) return null;
|
||||||
|
|
||||||
|
return JobAttendanceResponse.fromJson(parsedJson);
|
||||||
|
} catch (e, stack) {
|
||||||
|
_log("Exception in getJobAttendanceLog: $e\n$stack");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update Service Project Job Attendance
|
||||||
|
static Future<bool> updateServiceProjectJobAttendance({
|
||||||
|
required Map<String, dynamic> payload,
|
||||||
|
}) async {
|
||||||
|
const endpoint = ApiEndpoints.serviceProjectUpateJobAttendance;
|
||||||
|
|
||||||
|
logSafe("Updating Service Project Job Attendance with payload: $payload");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _postRequest(endpoint, payload);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Update Job Attendance failed: null response",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Update Job Attendance response status: ${response.statusCode}");
|
||||||
|
logSafe("Update Job Attendance response body: ${response.body}");
|
||||||
|
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
if (json['success'] == true) {
|
||||||
|
logSafe("Job Attendance updated successfully: ${json['data']}");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logSafe(
|
||||||
|
"Failed to update Job Attendance: ${json['message'] ?? 'Unknown error'}",
|
||||||
|
level: LogLevel.warning,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during updateServiceProjectJobAttendance: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Edit a Service Project Job
|
/// Edit a Service Project Job
|
||||||
static Future<bool> editServiceProjectJobApi({
|
static Future<bool> editServiceProjectJobApi({
|
||||||
required String jobId,
|
required String jobId,
|
||||||
|
|||||||
206
lib/model/service_project/job_attendance_logs_model.dart
Normal file
206
lib/model/service_project/job_attendance_logs_model.dart
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
|
||||||
|
class JobAttendanceResponse {
|
||||||
|
final bool success;
|
||||||
|
final String message;
|
||||||
|
final List<JobAttendanceData>? data;
|
||||||
|
final dynamic errors;
|
||||||
|
final int statusCode;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
JobAttendanceResponse({
|
||||||
|
required this.success,
|
||||||
|
required this.message,
|
||||||
|
required this.data,
|
||||||
|
this.errors,
|
||||||
|
required this.statusCode,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory JobAttendanceResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return JobAttendanceResponse(
|
||||||
|
success: json['success'] ?? false,
|
||||||
|
message: json['message'] ?? '',
|
||||||
|
data: (json['data'] as List<dynamic>?)
|
||||||
|
?.map((e) => JobAttendanceData.fromJson(e))
|
||||||
|
.toList(),
|
||||||
|
errors: json['errors'],
|
||||||
|
statusCode: json['statusCode'] ?? 0,
|
||||||
|
timestamp: DateTime.parse(json['timestamp']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'success': success,
|
||||||
|
'message': message,
|
||||||
|
'data': data?.map((e) => e.toJson()).toList(),
|
||||||
|
'errors': errors,
|
||||||
|
'statusCode': statusCode,
|
||||||
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class JobAttendanceData {
|
||||||
|
final String id;
|
||||||
|
final JobTicket jobTicket;
|
||||||
|
final Document? document;
|
||||||
|
final String? latitude;
|
||||||
|
final String? longitude;
|
||||||
|
final int action;
|
||||||
|
final String? comment;
|
||||||
|
final Employee employee;
|
||||||
|
final DateTime markedTime;
|
||||||
|
final DateTime markedAt;
|
||||||
|
|
||||||
|
JobAttendanceData({
|
||||||
|
required this.id,
|
||||||
|
required this.jobTicket,
|
||||||
|
this.document,
|
||||||
|
this.latitude,
|
||||||
|
this.longitude,
|
||||||
|
required this.action,
|
||||||
|
this.comment,
|
||||||
|
required this.employee,
|
||||||
|
required this.markedTime,
|
||||||
|
required this.markedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory JobAttendanceData.fromJson(Map<String, dynamic> json) {
|
||||||
|
return JobAttendanceData(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
jobTicket: JobTicket.fromJson(json['jobTicket']),
|
||||||
|
document: json['document'] != null
|
||||||
|
? Document.fromJson(json['document'])
|
||||||
|
: null,
|
||||||
|
latitude: json['latitude'],
|
||||||
|
longitude: json['longitude'],
|
||||||
|
action: json['action'] ?? 0,
|
||||||
|
comment: json['comment'],
|
||||||
|
employee: Employee.fromJson(json['employee']),
|
||||||
|
markedTime: DateTime.parse(json['markedTIme']),
|
||||||
|
markedAt: DateTime.parse(json['markedAt']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'jobTicket': jobTicket.toJson(),
|
||||||
|
'document': document?.toJson(),
|
||||||
|
'latitude': latitude,
|
||||||
|
'longitude': longitude,
|
||||||
|
'action': action,
|
||||||
|
'comment': comment,
|
||||||
|
'employee': employee.toJson(),
|
||||||
|
'markedTIme': markedTime.toIso8601String(),
|
||||||
|
'markedAt': markedAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class JobTicket {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final String jobTicketUId;
|
||||||
|
final String statusName;
|
||||||
|
|
||||||
|
JobTicket({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.jobTicketUId,
|
||||||
|
required this.statusName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory JobTicket.fromJson(Map<String, dynamic> json) {
|
||||||
|
return JobTicket(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
title: json['title'] ?? '',
|
||||||
|
description: json['description'] ?? '',
|
||||||
|
jobTicketUId: json['jobTicketUId'] ?? '',
|
||||||
|
statusName: json['statusName'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'jobTicketUId': jobTicketUId,
|
||||||
|
'statusName': statusName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class Document {
|
||||||
|
final String documentId;
|
||||||
|
final String fileName;
|
||||||
|
final String contentType;
|
||||||
|
final String preSignedUrl;
|
||||||
|
final String thumbPreSignedUrl;
|
||||||
|
|
||||||
|
Document({
|
||||||
|
required this.documentId,
|
||||||
|
required this.fileName,
|
||||||
|
required this.contentType,
|
||||||
|
required this.preSignedUrl,
|
||||||
|
required this.thumbPreSignedUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Document.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Document(
|
||||||
|
documentId: json['documentId'] ?? '',
|
||||||
|
fileName: json['fileName'] ?? '',
|
||||||
|
contentType: json['contentType'] ?? '',
|
||||||
|
preSignedUrl: json['preSignedUrl'] ?? '',
|
||||||
|
thumbPreSignedUrl: json['thumbPreSignedUrl'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'documentId': documentId,
|
||||||
|
'fileName': fileName,
|
||||||
|
'contentType': contentType,
|
||||||
|
'preSignedUrl': preSignedUrl,
|
||||||
|
'thumbPreSignedUrl': thumbPreSignedUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class Employee {
|
||||||
|
final String id;
|
||||||
|
final String firstName;
|
||||||
|
final String lastName;
|
||||||
|
final String email;
|
||||||
|
final String? photo;
|
||||||
|
final String jobRoleId;
|
||||||
|
final String jobRoleName;
|
||||||
|
|
||||||
|
Employee({
|
||||||
|
required this.id,
|
||||||
|
required this.firstName,
|
||||||
|
required this.lastName,
|
||||||
|
required this.email,
|
||||||
|
this.photo,
|
||||||
|
required this.jobRoleId,
|
||||||
|
required this.jobRoleName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Employee.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Employee(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
firstName: json['firstName'] ?? '',
|
||||||
|
lastName: json['lastName'] ?? '',
|
||||||
|
email: json['email'] ?? '',
|
||||||
|
photo: json['photo'],
|
||||||
|
jobRoleId: json['jobRoleId'] ?? '',
|
||||||
|
jobRoleName: json['jobRoleName'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'firstName': firstName,
|
||||||
|
'lastName': lastName,
|
||||||
|
'email': email,
|
||||||
|
'photo': photo,
|
||||||
|
'jobRoleId': jobRoleId,
|
||||||
|
'jobRoleName': jobRoleName,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -31,12 +31,15 @@ class JobData {
|
|||||||
final String id;
|
final String id;
|
||||||
final String title;
|
final String title;
|
||||||
final String description;
|
final String description;
|
||||||
|
final String jobTicketUId;
|
||||||
final Project project;
|
final Project project;
|
||||||
final List<Assignee> assignees;
|
final List<Assignee> assignees;
|
||||||
final Status status;
|
final Status status;
|
||||||
final String startDate;
|
final String startDate;
|
||||||
final String dueDate;
|
final String dueDate;
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
|
final dynamic taggingAction;
|
||||||
|
final int nextTaggingAction;
|
||||||
final String createdAt;
|
final String createdAt;
|
||||||
final User createdBy;
|
final User createdBy;
|
||||||
final List<Tag> tags;
|
final List<Tag> tags;
|
||||||
@ -46,12 +49,15 @@ class JobData {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.description,
|
required this.description,
|
||||||
|
required this.jobTicketUId,
|
||||||
required this.project,
|
required this.project,
|
||||||
required this.assignees,
|
required this.assignees,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.startDate,
|
required this.startDate,
|
||||||
required this.dueDate,
|
required this.dueDate,
|
||||||
required this.isActive,
|
required this.isActive,
|
||||||
|
this.taggingAction,
|
||||||
|
required this.nextTaggingAction,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.createdBy,
|
required this.createdBy,
|
||||||
required this.tags,
|
required this.tags,
|
||||||
@ -63,6 +69,7 @@ class JobData {
|
|||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
title: json['title'] as String,
|
title: json['title'] as String,
|
||||||
description: json['description'] as String,
|
description: json['description'] as String,
|
||||||
|
jobTicketUId: json['jobTicketUId'] as String,
|
||||||
project: Project.fromJson(json['project']),
|
project: Project.fromJson(json['project']),
|
||||||
assignees: (json['assignees'] as List<dynamic>)
|
assignees: (json['assignees'] as List<dynamic>)
|
||||||
.map((e) => Assignee.fromJson(e))
|
.map((e) => Assignee.fromJson(e))
|
||||||
@ -71,9 +78,12 @@ class JobData {
|
|||||||
startDate: json['startDate'] as String,
|
startDate: json['startDate'] as String,
|
||||||
dueDate: json['dueDate'] as String,
|
dueDate: json['dueDate'] as String,
|
||||||
isActive: json['isActive'] as bool,
|
isActive: json['isActive'] as bool,
|
||||||
|
taggingAction: json['taggingAction'],
|
||||||
|
nextTaggingAction: json['nextTaggingAction'] as int,
|
||||||
createdAt: json['createdAt'] as String,
|
createdAt: json['createdAt'] as String,
|
||||||
createdBy: User.fromJson(json['createdBy']),
|
createdBy: User.fromJson(json['createdBy']),
|
||||||
tags: (json['tags'] as List<dynamic>).map((e) => Tag.fromJson(e)).toList(),
|
tags:
|
||||||
|
(json['tags'] as List<dynamic>).map((e) => Tag.fromJson(e)).toList(),
|
||||||
updateLogs: (json['updateLogs'] as List<dynamic>)
|
updateLogs: (json['updateLogs'] as List<dynamic>)
|
||||||
.map((e) => UpdateLog.fromJson(e))
|
.map((e) => UpdateLog.fromJson(e))
|
||||||
.toList(),
|
.toList(),
|
||||||
|
|||||||
@ -12,6 +12,11 @@ import 'package:marco/helpers/services/api_service.dart';
|
|||||||
import 'package:marco/helpers/widgets/date_range_picker.dart';
|
import 'package:marco/helpers/widgets/date_range_picker.dart';
|
||||||
import 'package:marco/model/employees/multiple_select_bottomsheet.dart';
|
import 'package:marco/model/employees/multiple_select_bottomsheet.dart';
|
||||||
import 'package:marco/model/employees/employee_model.dart';
|
import 'package:marco/model/employees/employee_model.dart';
|
||||||
|
import 'package:marco/model/expense/comment_bottom_sheet.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class JobDetailsScreen extends StatefulWidget {
|
class JobDetailsScreen extends StatefulWidget {
|
||||||
final String jobId;
|
final String jobId;
|
||||||
@ -33,6 +38,7 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
final RxList<Assignee> _selectedAssignees = <Assignee>[].obs;
|
final RxList<Assignee> _selectedAssignees = <Assignee>[].obs;
|
||||||
final RxList<Tag> _selectedTags = <Tag>[].obs;
|
final RxList<Tag> _selectedTags = <Tag>[].obs;
|
||||||
final RxBool isEditing = false.obs;
|
final RxBool isEditing = false.obs;
|
||||||
|
File? imageAttachment;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -157,6 +163,48 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleTagAction() async {
|
||||||
|
final job = controller.jobDetail.value?.data;
|
||||||
|
if (job == null) return;
|
||||||
|
|
||||||
|
// Determine action based on current/next tagging state
|
||||||
|
final action = job.nextTaggingAction;
|
||||||
|
|
||||||
|
File? attachmentFile;
|
||||||
|
|
||||||
|
// Step 1: Ask for comment first (optional)
|
||||||
|
final comment = await showCommentBottomSheet(
|
||||||
|
context, action == 0 ? "Tag In" : "Tag Out");
|
||||||
|
|
||||||
|
// Step 2: Ask for image optionally using your custom ConfirmDialog
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => ConfirmDialog(
|
||||||
|
title: "Attach Image?",
|
||||||
|
message: "Do you want to attach an image for this action?",
|
||||||
|
confirmText: "Yes",
|
||||||
|
cancelText: "No",
|
||||||
|
icon: Icons.camera_alt_outlined,
|
||||||
|
confirmColor: Colors.blueAccent,
|
||||||
|
onConfirm: () async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final picked = await picker.pickImage(source: ImageSource.camera);
|
||||||
|
if (picked != null) {
|
||||||
|
attachmentFile = File(picked.path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 3: Perform attendance using controller
|
||||||
|
await controller.updateJobAttendance(
|
||||||
|
jobId: job.id,
|
||||||
|
action: action,
|
||||||
|
comment: comment ?? "",
|
||||||
|
attachment: attachmentFile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSectionCard({
|
Widget _buildSectionCard({
|
||||||
required String title,
|
required String title,
|
||||||
required IconData titleIcon,
|
required IconData titleIcon,
|
||||||
@ -392,6 +440,236 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildAttendanceCard() {
|
||||||
|
return Obx(() {
|
||||||
|
final job = controller.jobDetail.value?.data;
|
||||||
|
final isLoading = controller.isTagging.value;
|
||||||
|
final action = job?.nextTaggingAction ?? 0;
|
||||||
|
final RxBool isExpanded = false.obs;
|
||||||
|
final logs = controller.attendanceLog.value?.data ?? [];
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 3,
|
||||||
|
shadowColor: Colors.black12,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.access_time_outlined,
|
||||||
|
size: 20, color: Colors.blueAccent),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
MyText.bodyLarge("Attendance", fontWeight: 700, fontSize: 16),
|
||||||
|
const Spacer(),
|
||||||
|
Obx(() => IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
isExpanded.value
|
||||||
|
? Icons.expand_less
|
||||||
|
: Icons.expand_more,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
isExpanded.value = !isExpanded.value;
|
||||||
|
// Fetch attendance logs only when expanded
|
||||||
|
if (isExpanded.value && job != null) {
|
||||||
|
await controller.fetchJobAttendanceLog(job.jobTicketUId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Divider(),
|
||||||
|
|
||||||
|
// Tag In/Tag Out Button
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: isLoading
|
||||||
|
? SizedBox(
|
||||||
|
height: 16,
|
||||||
|
width: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(action == 0 ? Icons.login : Icons.logout),
|
||||||
|
label: MyText.bodyMedium(
|
||||||
|
action == 0 ? "Tag In" : "Tag Out",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed:
|
||||||
|
isLoading || job == null ? null : _handleTagAction,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
backgroundColor: action == 0 ? Colors.green : Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Attendance Logs List
|
||||||
|
Obx(() {
|
||||||
|
if (!isExpanded.value) return Container();
|
||||||
|
|
||||||
|
if (logs.isEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: MyText.bodyMedium(
|
||||||
|
"No attendance logs available",
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
itemCount: logs.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (_, index) {
|
||||||
|
final log = logs[index];
|
||||||
|
final employeeName =
|
||||||
|
"${log.employee.firstName} ${log.employee.lastName}";
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header: Icon + Employee + Date + Time
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
log.action == 0 ? Icons.login : Icons.logout,
|
||||||
|
color:
|
||||||
|
log.action == 0 ? Colors.green : Colors.red,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: MyText.bodyMedium(
|
||||||
|
"$employeeName | ${DateTimeUtils.convertUtcToLocal(log.markedAt.toIso8601String(), format: 'd MMM yyyy')}",
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MyText.bodySmall(
|
||||||
|
"Time: ${DateTimeUtils.convertUtcToLocal(log.markedAt.toIso8601String(), format: 'hh:mm a')}",
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Divider(height: 1, color: Colors.grey),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Comment / Description
|
||||||
|
MyText.bodySmall(
|
||||||
|
"Description: ${log.comment?.isNotEmpty == true ? log.comment : 'No description provided'}",
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Location
|
||||||
|
if (log.latitude != null && log.longitude != null)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final lat =
|
||||||
|
double.tryParse(log.latitude!) ?? 0.0;
|
||||||
|
final lon =
|
||||||
|
double.tryParse(log.longitude!) ?? 0.0;
|
||||||
|
final url =
|
||||||
|
'https://www.google.com/maps/search/?api=1&query=$lat,$lon';
|
||||||
|
if (await canLaunchUrl(Uri.parse(url))) {
|
||||||
|
await launchUrl(Uri.parse(url),
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.location_on,
|
||||||
|
size: 16, color: Colors.blue),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
MyText.bodySmall(
|
||||||
|
"View Location",
|
||||||
|
color: Colors.blue,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Attached Image
|
||||||
|
if (log.document != null)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => Dialog(
|
||||||
|
child: Image.network(
|
||||||
|
log.document!.preSignedUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
height: 400,
|
||||||
|
errorBuilder: (_, __, ___) => const Icon(
|
||||||
|
Icons.broken_image,
|
||||||
|
size: 50,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.network(
|
||||||
|
log.document!.thumbPreSignedUrl.isNotEmpty
|
||||||
|
? log.document!.thumbPreSignedUrl
|
||||||
|
: log.document!.preSignedUrl,
|
||||||
|
height: 60,
|
||||||
|
width: 60,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => const Icon(
|
||||||
|
Icons.broken_image,
|
||||||
|
size: 40,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@ -402,7 +680,12 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
floatingActionButton: Obx(() => FloatingActionButton.extended(
|
floatingActionButton: Obx(() => FloatingActionButton.extended(
|
||||||
onPressed:
|
onPressed:
|
||||||
isEditing.value ? _editJob : () => isEditing.value = true,
|
isEditing.value ? _editJob : () => isEditing.value = true,
|
||||||
label: Text(isEditing.value ? "Save" : "Edit"),
|
backgroundColor: contentTheme.primary,
|
||||||
|
label: MyText.bodyMedium(
|
||||||
|
isEditing.value ? "Save" : "Edit",
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
icon: Icon(isEditing.value ? Icons.save : Icons.edit),
|
icon: Icon(isEditing.value ? Icons.save : Icons.edit),
|
||||||
)),
|
)),
|
||||||
body: Obx(() {
|
body: Obx(() {
|
||||||
@ -425,6 +708,7 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
_buildAttendanceCard(),
|
||||||
_buildSectionCard(
|
_buildSectionCard(
|
||||||
title: "Job Info",
|
title: "Job Info",
|
||||||
titleIcon: Icons.task_outlined,
|
titleIcon: Icons.task_outlined,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user