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,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<ProjectDetail>();
var jobList = <JobEntity>[].obs;
var jobDetail = Rxn<JobDetailsResponse>();
@ -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<JobAttendanceResponse>();
// -------------------- Lifecycle --------------------
@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 --------------------
Future<void> fetchProjectJobs({bool initialLoad = false}) async {
if (projectId.value.isEmpty && !initialLoad) {
@ -142,4 +177,99 @@ class ServiceProjectDetailsController extends GetxController {
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";
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";
}

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/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<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
static Future<bool> editServiceProjectJobApi({
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 title;
final String description;
final String jobTicketUId;
final Project project;
final List<Assignee> 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<Tag> 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<dynamic>)
.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<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>)
.map((e) => UpdateLog.fromJson(e))
.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/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<JobDetailsScreen> with UIMixin {
final RxList<Assignee> _selectedAssignees = <Assignee>[].obs;
final RxList<Tag> _selectedTags = <Tag>[].obs;
final RxBool isEditing = false.obs;
File? imageAttachment;
@override
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({
required String title,
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
Widget build(BuildContext context) {
return Scaffold(
@ -402,7 +680,12 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> 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<JobDetailsScreen> with UIMixin {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAttendanceCard(),
_buildSectionCard(
title: "Job Info",
titleIcon: Icons.task_outlined,