added tag in tag out api and code

This commit is contained in:
Vaibhav Surve 2025-11-17 17:36:11 +05:30
parent 605617695e
commit 919310644d
6 changed files with 700 additions and 3 deletions

View File

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

View File

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

View File

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

View 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,
};
}

View File

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

View File

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