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({
@ -444,10 +457,11 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
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 ?? [];
if (job == null) return const SizedBox();
return Card( return Card(
elevation: 3, elevation: 3,
shadowColor: Colors.black12, shadowColor: Colors.black12,
@ -468,25 +482,26 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
const Spacer(), const Spacer(),
Obx(() => IconButton( Obx(() => IconButton(
icon: Icon( icon: Icon(
isExpanded.value isAttendanceExpanded.value
? Icons.expand_less ? Icons.expand_less
: Icons.expand_more, : Icons.expand_more,
color: Colors.grey[600], color: Colors.grey[600],
), ),
onPressed: () async { onPressed: () async {
isExpanded.value = !isExpanded.value; isAttendanceExpanded.value = !isAttendanceExpanded.value;
// Fetch attendance logs only when expanded if (isAttendanceExpanded.value && job != null) {
if (isExpanded.value && job != null) { await controller.fetchJobAttendanceLog(
await controller.fetchJobAttendanceLog(job.attendanceId ?? ''); job.attendanceId ?? '');
} }
}, },
)), ))
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Divider(), const Divider(),
// Tag In/Tag Out Button // 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,9 +534,16 @@ 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 (isAttendanceLogLoading.value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(child: CircularProgressIndicator()),
);
}
if (logs.isEmpty) { if (logs.isEmpty) {
return Padding( return Padding(
@ -539,67 +560,71 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.only(top: 12), padding: const EdgeInsets.only(top: 12),
itemCount: logs.length, itemCount: logs.length,
separatorBuilder: (_, __) => const SizedBox(height: 12), separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (_, index) { itemBuilder: (_, index) {
final log = logs[index]; final log = logs[index];
final employeeName = final employeeName =
"${log.employee.firstName} ${log.employee.lastName}"; "${log.employee.firstName} ${log.employee.lastName}";
final date =
DateTimeUtils.convertUtcToLocal(log.markedAt.toIso8601String(),
format: 'd MMM yyyy');
final time =
DateTimeUtils.convertUtcToLocal(log.markedAt.toIso8601String(),
format: 'hh:mm a');
return Container( return Card(
decoration: BoxDecoration( elevation: 1,
color: Colors.grey.shade50, shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(8)),
boxShadow: [ child: Padding(
BoxShadow( padding: const EdgeInsets.symmetric(
color: Colors.black.withOpacity(0.05), horizontal: 12, vertical: 8),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(12),
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),
child: Text(
log.comment!,
style: const TextStyle(fontSize: 13),
),
), ),
const SizedBox(height: 8),
// 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,31 +632,38 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
mode: LaunchMode.externalApplication); mode: LaunchMode.externalApplication);
} }
}, },
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: Row( child: Row(
children: [ mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.location_on, Icon(Icons.location_on,
size: 16, color: Colors.blue), size: 14, color: Colors.blue),
const SizedBox(width: 4), SizedBox(width: 4),
MyText.bodySmall( Text(
"View Location", "View Location",
style: TextStyle(
fontSize: 12,
color: Colors.blue, color: Colors.blue,
decoration: TextDecoration.underline, decoration: TextDecoration.underline),
), ),
], ],
), ),
), ),
const SizedBox(height: 8), ),
// Attached Image // Attached Image
if (log.document != null) if (log.document != null)
GestureDetector( Padding(
padding: const EdgeInsets.only(top: 4),
child: GestureDetector(
onTap: () => showDialog( onTap: () => showDialog(
context: context, context: context,
builder: (_) => Dialog( builder: (_) => Dialog(
child: Image.network( child: Image.network(
log.document!.preSignedUrl, log.document!.preSignedUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
height: 400, height: 250,
errorBuilder: (_, __, ___) => const Icon( errorBuilder: (_, __, ___) => const Icon(
Icons.broken_image, Icons.broken_image,
size: 50, size: 50,
@ -641,13 +673,13 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
), ),
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(6),
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: 60, height: 50,
width: 60, width: 50,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon( errorBuilder: (_, __, ___) => const Icon(
Icons.broken_image, Icons.broken_image,
@ -657,8 +689,10 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
), ),
), ),
), ),
),
], ],
), ),
),
); );
}, },
); );
@ -668,7 +702,7 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
), ),
); );
}); });
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {