feat: update API endpoints and enhance attendance log handling in job detail screen

This commit is contained in:
Vaibhav Surve 2025-11-19 15:44:55 +05:30
parent d05e26bc87
commit bbadcc4139
2 changed files with 194 additions and 158 deletions

View File

@ -1,8 +1,10 @@
class ApiEndpoints { class ApiEndpoints {
static const String baseUrl = "https://stageapi.marcoaiot.com/api"; // static const String baseUrl = "https://stageapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api"; // static const String baseUrl = "https://devapi.marcoaiot.com/api";
// static const String baseUrl = "https://mapi.marcoaiot.com/api"; // static const String baseUrl = "https://mapi.marcoaiot.com/api";
static const String baseUrl = "https://api.onfieldwork.com/api";
static const String getMasterCurrencies = "/Master/currencies/list"; static const String getMasterCurrencies = "/Master/currencies/list";
static const String getMasterExpensesCategories = static const String getMasterExpensesCategories =
@ -145,7 +147,7 @@ class ApiEndpoints {
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 serviceProjectUpateJobAttendance = "/serviceproject/job/attendance";
static const String serviceProjectUpateJobAttendanceLog = "/job/attendance/log"; static const String serviceProjectUpateJobAttendanceLog = "/serviceproject/job/attendance/log";
static const String getServiceProjectUpateJobAllocationList = "/serviceproject/get/allocation/list"; static const String getServiceProjectUpateJobAllocationList = "/serviceproject/get/allocation/list";
static const String manageServiceProjectUpateJobAllocation = "/serviceproject/manage/allocation"; static const String manageServiceProjectUpateJobAllocation = "/serviceproject/manage/allocation";
static const String getTeamRoles = "/master/team-roles/list"; static const String getTeamRoles = "/master/team-roles/list";

View File

@ -28,6 +28,8 @@ class JobDetailsScreen extends StatefulWidget {
class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin { class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
late final ServiceProjectDetailsController controller; late final ServiceProjectDetailsController controller;
final RxBool isAttendanceExpanded = false.obs;
RxBool isAttendanceLogLoading = false.obs;
final TextEditingController _titleController = TextEditingController(); final TextEditingController _titleController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController(); final TextEditingController _descriptionController = TextEditingController();
@ -152,7 +154,9 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
} }
final success = await ApiService.editServiceProjectJobApi( final success = await ApiService.editServiceProjectJobApi(
jobId: job.id, operations: operations); jobId: job.id,
operations: operations,
);
if (success) { if (success) {
Get.snackbar("Success", "Job updated successfully"); Get.snackbar("Success", "Job updated successfully");
@ -167,16 +171,15 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
final job = controller.jobDetail.value?.data; final job = controller.jobDetail.value?.data;
if (job == null) return; if (job == null) return;
// Determine action based on current/next tagging state
final action = job.nextTaggingAction; final action = job.nextTaggingAction;
File? attachmentFile; File? attachmentFile;
// Step 1: Ask for comment first (optional) // Step 1: Show comment bottom sheet
final comment = await showCommentBottomSheet( final comment = await showCommentBottomSheet(
context, action == 0 ? "Tag In" : "Tag Out"); context, action == 0 ? "Tag In" : "Tag Out");
if (comment == null) return; // User cancelled
// Step 2: Ask for image optionally using your custom ConfirmDialog // Step 2: Ask for optional image
await showDialog( await showDialog(
context: context, context: context,
builder: (_) => ConfirmDialog( builder: (_) => ConfirmDialog(
@ -196,13 +199,23 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
), ),
); );
// Step 3: Perform attendance using controller // Step 3: Call attendance update
await controller.updateJobAttendance( await controller.updateJobAttendance(
jobId: job.id, jobId: job.id,
action: action == 0 ? 0 : 1, action: action == 0 ? 0 : 1,
comment: comment ?? "", comment: comment,
attachment: attachmentFile, attachment: attachmentFile,
); );
// Step 4: Check message to detect failure
if (controller.attendanceMessage.value.toLowerCase().contains("failed") ||
controller.attendanceMessage.value.toLowerCase().contains("error")) {
Get.snackbar("Error", controller.attendanceMessage.value);
return; // Do NOT close bottom sheet
}
// Success
Get.snackbar("Success", controller.attendanceMessage.value);
} }
Widget _buildSectionCard({ Widget _buildSectionCard({
@ -441,52 +454,54 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
} }
Widget _buildAttendanceCard() { Widget _buildAttendanceCard() {
return Obx(() { return Obx(() {
final job = controller.jobDetail.value?.data; final job = controller.jobDetail.value?.data;
final isLoading = controller.isTagging.value; final isLoading = controller.isTagging.value;
final action = job?.nextTaggingAction ?? 0; final action = job?.nextTaggingAction;
final RxBool isExpanded = false.obs; final logs = controller.attendanceLog.value?.data ?? [];
final logs = controller.attendanceLog.value?.data ?? [];
return Card( if (job == null) return const SizedBox();
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.attendanceId ?? '');
}
},
)),
],
),
const SizedBox(height: 8),
const Divider(),
// Tag In/Tag Out Button 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(
isAttendanceExpanded.value
? Icons.expand_less
: Icons.expand_more,
color: Colors.grey[600],
),
onPressed: () async {
isAttendanceExpanded.value = !isAttendanceExpanded.value;
if (isAttendanceExpanded.value && job != null) {
await controller.fetchJobAttendanceLog(
job.attendanceId ?? '');
}
},
))
],
),
const SizedBox(height: 8),
const Divider(),
// Tag In/Tag Out Button
if (action != null)
Align( Align(
alignment: Alignment.center, alignment: Alignment.center,
child: SizedBox( child: SizedBox(
@ -507,8 +522,7 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
fontWeight: 600, fontWeight: 600,
color: Colors.white, color: Colors.white,
), ),
onPressed: onPressed: isLoading ? null : _handleTagAction,
isLoading || job == null ? null : _handleTagAction,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@ -520,86 +534,97 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
), ),
), ),
// Attendance Logs List // Attendance Logs
Obx(() { Obx(() {
if (!isExpanded.value) return Container(); if (!isAttendanceExpanded.value) return Container();
if (logs.isEmpty) { if (isAttendanceLogLoading.value) {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 12), padding: const EdgeInsets.symmetric(vertical: 12),
child: MyText.bodyMedium( child: Center(child: CircularProgressIndicator()),
"No attendance logs available", );
color: Colors.grey[600], }
),
);
}
return ListView.separated( if (logs.isEmpty) {
shrinkWrap: true, return Padding(
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.only(top: 12), padding: const EdgeInsets.only(top: 12),
itemCount: logs.length, child: MyText.bodyMedium(
separatorBuilder: (_, __) => const SizedBox(height: 12), "No attendance logs available",
itemBuilder: (_, index) { color: Colors.grey[600],
final log = logs[index]; ),
final employeeName = );
"${log.employee.firstName} ${log.employee.lastName}"; }
return Container( return ListView.separated(
decoration: BoxDecoration( shrinkWrap: true,
color: Colors.grey.shade50, physics: const NeverScrollableScrollPhysics(),
borderRadius: BorderRadius.circular(12), padding: const EdgeInsets.only(top: 12),
boxShadow: [ itemCount: logs.length,
BoxShadow( separatorBuilder: (_, __) => const SizedBox(height: 8),
color: Colors.black.withOpacity(0.05), itemBuilder: (_, index) {
blurRadius: 6, final log = logs[index];
offset: const Offset(0, 2), final employeeName =
), "${log.employee.firstName} ${log.employee.lastName}";
], final date =
), DateTimeUtils.convertUtcToLocal(log.markedAt.toIso8601String(),
padding: const EdgeInsets.all(12), format: 'd MMM yyyy');
final time =
DateTimeUtils.convertUtcToLocal(log.markedAt.toIso8601String(),
format: 'hh:mm a');
return Card(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Header: Icon + Employee + Date + Time // Top Row: Icon, Employee, Date, Time
Row( Row(
children: [ children: [
Icon( Icon(
log.action == 0 ? Icons.login : Icons.logout, log.action == 0 ? Icons.login : Icons.logout,
color: color: log.action == 0
log.action == 0 ? Colors.green : Colors.red, ? Colors.green
: Colors.red,
size: 18,
), ),
const SizedBox(width: 8), const SizedBox(width: 6),
Expanded( Expanded(
child: MyText.bodyMedium( child: Text(
"$employeeName | ${DateTimeUtils.convertUtcToLocal(log.markedAt.toIso8601String(), format: 'd MMM yyyy')}", employeeName,
fontWeight: 600, style: const TextStyle(
fontWeight: FontWeight.w600),
), ),
), ),
MyText.bodySmall( Text(
"Time: ${DateTimeUtils.convertUtcToLocal(log.markedAt.toIso8601String(), format: 'hh:mm a')}", "$date | $time",
color: Colors.grey[700], style: TextStyle(
fontSize: 12, color: Colors.grey[700]),
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 4),
const Divider(height: 1, color: Colors.grey),
const SizedBox(height: 8),
// Comment / Description // Comment
MyText.bodySmall( if (log.comment?.isNotEmpty == true)
"Description: ${log.comment?.isNotEmpty == true ? log.comment : 'No description provided'}", Padding(
), padding: const EdgeInsets.only(top: 4),
const SizedBox(height: 8), child: Text(
log.comment!,
style: const TextStyle(fontSize: 13),
),
),
// Location // Location
if (log.latitude != null && log.longitude != null) if (log.latitude != null && log.longitude != null)
GestureDetector( GestureDetector(
onTap: () async { onTap: () async {
final lat = final lat = double.tryParse(log.latitude!) ?? 0.0;
double.tryParse(log.latitude!) ?? 0.0; final lon = double.tryParse(log.longitude!) ?? 0.0;
final lon =
double.tryParse(log.longitude!) ?? 0.0;
final url = final url =
'https://www.google.com/maps/search/?api=1&query=$lat,$lon'; 'https://www.google.com/maps/search/?api=1&query=$lat,$lon';
if (await canLaunchUrl(Uri.parse(url))) { if (await canLaunchUrl(Uri.parse(url))) {
@ -607,68 +632,77 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
mode: LaunchMode.externalApplication); mode: LaunchMode.externalApplication);
} }
}, },
child: Row( child: Padding(
children: [ padding: const EdgeInsets.only(top: 4),
Icon(Icons.location_on, child: Row(
size: 16, color: Colors.blue), mainAxisSize: MainAxisSize.min,
const SizedBox(width: 4), children: const [
MyText.bodySmall( Icon(Icons.location_on,
"View Location", size: 14, color: Colors.blue),
color: Colors.blue, SizedBox(width: 4),
decoration: TextDecoration.underline, Text(
), "View Location",
], style: TextStyle(
fontSize: 12,
color: Colors.blue,
decoration: TextDecoration.underline),
),
],
),
), ),
), ),
const SizedBox(height: 8),
// Attached Image // Attached Image
if (log.document != null) if (log.document != null)
GestureDetector( Padding(
onTap: () => showDialog( padding: const EdgeInsets.only(top: 4),
context: context, child: GestureDetector(
builder: (_) => Dialog( onTap: () => showDialog(
child: Image.network( context: context,
log.document!.preSignedUrl, builder: (_) => Dialog(
fit: BoxFit.cover, child: Image.network(
height: 400, log.document!.preSignedUrl,
errorBuilder: (_, __, ___) => const Icon( fit: BoxFit.cover,
Icons.broken_image, height: 250,
size: 50, errorBuilder: (_, __, ___) => const Icon(
color: Colors.grey, Icons.broken_image,
size: 50,
color: Colors.grey,
),
), ),
), ),
), ),
), child: ClipRRect(
child: ClipRRect( borderRadius: BorderRadius.circular(6),
borderRadius: BorderRadius.circular(8), child: Image.network(
child: Image.network( log.document!.thumbPreSignedUrl.isNotEmpty
log.document!.thumbPreSignedUrl.isNotEmpty ? log.document!.thumbPreSignedUrl
? log.document!.thumbPreSignedUrl : log.document!.preSignedUrl,
: log.document!.preSignedUrl, height: 50,
height: 60, width: 50,
width: 60, fit: BoxFit.cover,
fit: BoxFit.cover, errorBuilder: (_, __, ___) => const Icon(
errorBuilder: (_, __, ___) => const Icon( Icons.broken_image,
Icons.broken_image, size: 40,
size: 40, color: Colors.grey,
color: Colors.grey, ),
), ),
), ),
), ),
), ),
], ],
), ),
); ),
}, );
); },
}), );
], }),
), ],
), ),
); ),
}); );
} });
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {