From 919310644dd5d8255e639c4b6ceb1c75ad5484b7 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 17 Nov 2025 17:36:11 +0530 Subject: [PATCH] added tag in tag out api and code --- ...ice_project_details_screen_controller.dart | 132 +++++++- lib/helpers/services/api_endpoints.dart | 2 + lib/helpers/services/api_service.dart | 65 ++++ .../job_attendance_logs_model.dart | 206 +++++++++++++ .../service_project_job_detail_model.dart | 12 +- .../service_project_job_detail_screen.dart | 286 +++++++++++++++++- 6 files changed, 700 insertions(+), 3 deletions(-) create mode 100644 lib/model/service_project/job_attendance_logs_model.dart diff --git a/lib/controller/service_project/service_project_details_screen_controller.dart b/lib/controller/service_project/service_project_details_screen_controller.dart index b09a150..9be3782 100644 --- a/lib/controller/service_project/service_project_details_screen_controller.dart +++ b/lib/controller/service_project/service_project_details_screen_controller.dart @@ -3,10 +3,16 @@ 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/job_list_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 { // -------------------- Observables -------------------- - var projectId = ''.obs; + var projectId = ''.obs; var projectDetail = Rxn(); var jobList = [].obs; var jobDetail = Rxn(); @@ -25,6 +31,10 @@ class ServiceProjectDetailsController extends GetxController { var pageNumber = 1; final int pageSize = 20; var hasMoreJobs = true.obs; +// Inside ServiceProjectDetailsController + var isTagging = false.obs; + var attendanceMessage = ''.obs; +var attendanceLog = Rxn(); // -------------------- Lifecycle -------------------- @override @@ -68,6 +78,31 @@ class ServiceProjectDetailsController extends GetxController { } } +// Add this method to your controller + Future 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 -------------------- Future fetchProjectJobs({bool initialLoad = false}) async { if (projectId.value.isEmpty && !initialLoad) { @@ -142,4 +177,99 @@ class ServiceProjectDetailsController extends GetxController { isJobDetailLoading.value = false; } } + + Future _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 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? 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; + } + } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 0f67c10..5e1037f 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -144,4 +144,6 @@ class ApiEndpoints { "/serviceproject/job/details"; static const String editServiceProjectJob = "/serviceproject/job/edit"; static const String createServiceProjectJob = "/serviceproject/job/create"; + static const String serviceProjectUpateJobAttendance = "/serviceproject/job/attendance"; + static const String serviceProjectUpateJobAttendanceLog = "/job/attendance/log"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 968ad90..fe0e366 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -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/job_list_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 { static const bool enableLogs = true; @@ -307,6 +308,70 @@ class ApiService { } // Service Project Module APIs + /// Fetch Job Attendance Log by ID + static Future 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 updateServiceProjectJobAttendance({ + required Map 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 static Future editServiceProjectJobApi({ required String jobId, diff --git a/lib/model/service_project/job_attendance_logs_model.dart b/lib/model/service_project/job_attendance_logs_model.dart new file mode 100644 index 0000000..be95a5f --- /dev/null +++ b/lib/model/service_project/job_attendance_logs_model.dart @@ -0,0 +1,206 @@ + +class JobAttendanceResponse { + final bool success; + final String message; + final List? 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 json) { + return JobAttendanceResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: (json['data'] as List?) + ?.map((e) => JobAttendanceData.fromJson(e)) + .toList(), + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map 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 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 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 json) { + return JobTicket( + id: json['id'] ?? '', + title: json['title'] ?? '', + description: json['description'] ?? '', + jobTicketUId: json['jobTicketUId'] ?? '', + statusName: json['statusName'] ?? '', + ); + } + + Map 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 json) { + return Document( + documentId: json['documentId'] ?? '', + fileName: json['fileName'] ?? '', + contentType: json['contentType'] ?? '', + preSignedUrl: json['preSignedUrl'] ?? '', + thumbPreSignedUrl: json['thumbPreSignedUrl'] ?? '', + ); + } + + Map 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 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 toJson() => { + 'id': id, + 'firstName': firstName, + 'lastName': lastName, + 'email': email, + 'photo': photo, + 'jobRoleId': jobRoleId, + 'jobRoleName': jobRoleName, + }; +} diff --git a/lib/model/service_project/service_project_job_detail_model.dart b/lib/model/service_project/service_project_job_detail_model.dart index a74f309..55c66bf 100644 --- a/lib/model/service_project/service_project_job_detail_model.dart +++ b/lib/model/service_project/service_project_job_detail_model.dart @@ -31,12 +31,15 @@ class JobData { final String id; final String title; final String description; + final String jobTicketUId; final Project project; final List assignees; final Status status; final String startDate; final String dueDate; final bool isActive; + final dynamic taggingAction; + final int nextTaggingAction; final String createdAt; final User createdBy; final List tags; @@ -46,12 +49,15 @@ class JobData { required this.id, required this.title, required this.description, + required this.jobTicketUId, required this.project, required this.assignees, required this.status, required this.startDate, required this.dueDate, required this.isActive, + this.taggingAction, + required this.nextTaggingAction, required this.createdAt, required this.createdBy, required this.tags, @@ -63,6 +69,7 @@ class JobData { id: json['id'] as String, title: json['title'] as String, description: json['description'] as String, + jobTicketUId: json['jobTicketUId'] as String, project: Project.fromJson(json['project']), assignees: (json['assignees'] as List) .map((e) => Assignee.fromJson(e)) @@ -71,9 +78,12 @@ class JobData { startDate: json['startDate'] as String, dueDate: json['dueDate'] as String, isActive: json['isActive'] as bool, + taggingAction: json['taggingAction'], + nextTaggingAction: json['nextTaggingAction'] as int, createdAt: json['createdAt'] as String, createdBy: User.fromJson(json['createdBy']), - tags: (json['tags'] as List).map((e) => Tag.fromJson(e)).toList(), + tags: + (json['tags'] as List).map((e) => Tag.fromJson(e)).toList(), updateLogs: (json['updateLogs'] as List) .map((e) => UpdateLog.fromJson(e)) .toList(), diff --git a/lib/view/service_project/service_project_job_detail_screen.dart b/lib/view/service_project/service_project_job_detail_screen.dart index 2ed6ee4..b0488c3 100644 --- a/lib/view/service_project/service_project_job_detail_screen.dart +++ b/lib/view/service_project/service_project_job_detail_screen.dart @@ -12,6 +12,11 @@ import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/widgets/date_range_picker.dart'; import 'package:marco/model/employees/multiple_select_bottomsheet.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 { final String jobId; @@ -33,6 +38,7 @@ class _JobDetailsScreenState extends State with UIMixin { final RxList _selectedAssignees = [].obs; final RxList _selectedTags = [].obs; final RxBool isEditing = false.obs; + File? imageAttachment; @override void initState() { @@ -157,6 +163,48 @@ class _JobDetailsScreenState extends State with UIMixin { } } + Future _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({ required String title, required IconData titleIcon, @@ -392,6 +440,236 @@ class _JobDetailsScreenState extends State 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 Widget build(BuildContext context) { return Scaffold( @@ -402,7 +680,12 @@ class _JobDetailsScreenState extends State with UIMixin { floatingActionButton: Obx(() => FloatingActionButton.extended( onPressed: 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), )), body: Obx(() { @@ -425,6 +708,7 @@ class _JobDetailsScreenState extends State with UIMixin { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildAttendanceCard(), _buildSectionCard( title: "Job Info", titleIcon: Icons.task_outlined,