From ec6a45ed4357c22988653e65164f0d0e4f391846 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 19 Nov 2025 17:29:10 +0530 Subject: [PATCH] feat: integrate snackbar notifications for document and job updates in user document controller and job detail screen --- .../document/user_document_controller.dart | 43 +- .../service_project_job_detail_screen.dart | 477 +++++++++--------- 2 files changed, 272 insertions(+), 248 deletions(-) diff --git a/lib/controller/document/user_document_controller.dart b/lib/controller/document/user_document_controller.dart index 5667003..958dade 100644 --- a/lib/controller/document/user_document_controller.dart +++ b/lib/controller/document/user_document_controller.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/model/document/document_filter_model.dart'; import 'package:marco/model/document/documents_list_model.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; class DocumentController extends GetxController { // ==================== Observables ==================== @@ -38,7 +39,6 @@ class DocumentController extends GetxController { final endDate = Rxn(); // ==================== Lifecycle ==================== - @override void onClose() { // Don't dispose searchController here - it's managed by the page @@ -87,13 +87,23 @@ class DocumentController extends GetxController { entityId: entityId, reset: true, ); + + // Show success snackbar + showAppSnackbar( + title: 'Success', + message: isActive ? 'Document deactivated' : 'Document activated', + type: SnackbarType.success, + ); + return true; } else { errorMessage.value = 'Failed to update document state'; + _showError('Failed to update document state'); return false; } } catch (e) { errorMessage.value = 'Error updating document: $e'; + _showError('Error updating document: $e'); debugPrint('❌ Error toggling document state: $e'); return false; } finally { @@ -110,17 +120,13 @@ class DocumentController extends GetxController { bool reset = false, }) async { try { - // Reset pagination if needed if (reset) { pageNumber.value = 1; documents.clear(); hasMore.value = true; } - // Don't fetch if no more data if (!hasMore.value && !reset) return; - - // Prevent duplicate requests if (isLoading.value) return; isLoading.value = true; @@ -147,12 +153,24 @@ class DocumentController extends GetxController { errorMessage.value = response?.message ?? 'Failed to fetch documents'; if (documents.isEmpty) { _showError('Failed to load documents'); + } else { + showAppSnackbar( + title: 'Warning', + message: 'No more documents to load', + type: SnackbarType.warning, + ); } } } catch (e) { errorMessage.value = 'Error fetching documents: $e'; if (documents.isEmpty) { _showError('Error loading documents'); + } else { + showAppSnackbar( + title: 'Error', + message: 'Error fetching additional documents', + type: SnackbarType.error, + ); } debugPrint('❌ Error fetching documents: $e'); } finally { @@ -185,17 +203,12 @@ class DocumentController extends GetxController { isVerified.value != null; } - /// Show error message + /// Show error message via snackbar void _showError(String message) { - Get.snackbar( - 'Error', - message, - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red.shade100, - colorText: Colors.red.shade900, - margin: const EdgeInsets.all(16), - borderRadius: 8, - duration: const Duration(seconds: 3), + showAppSnackbar( + title: 'Error', + message: message, + type: SnackbarType.error, ); } 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 cb30c40..8881b87 100644 --- a/lib/view/service_project/service_project_job_detail_screen.dart +++ b/lib/view/service_project/service_project_job_detail_screen.dart @@ -17,6 +17,7 @@ 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'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; class JobDetailsScreen extends StatefulWidget { final String jobId; @@ -128,7 +129,6 @@ class _JobDetailsScreenState extends State with UIMixin { {"op": "replace", "path": "/assignees", "value": assigneesPayload}); final originalTags = job.tags; - final replaceTagsPayload = originalTags.map((t) { final isSelected = _selectedTags.any((s) => s.id == t.id); return {"id": t.id, "name": t.name, "isActive": isSelected}; @@ -149,7 +149,10 @@ class _JobDetailsScreenState extends State with UIMixin { } if (operations.isEmpty) { - Get.snackbar("Info", "No changes detected to save."); + showAppSnackbar( + title: "Info", + message: "No changes detected to save.", + type: SnackbarType.info); return; } @@ -159,11 +162,17 @@ class _JobDetailsScreenState extends State with UIMixin { ); if (success) { - Get.snackbar("Success", "Job updated successfully"); + showAppSnackbar( + title: "Success", + message: "Job updated successfully", + type: SnackbarType.success); await controller.fetchJobDetail(widget.jobId); isEditing.value = false; } else { - Get.snackbar("Error", "Failed to update job. Check inputs or try again."); + showAppSnackbar( + title: "Error", + message: "Failed to update job. Check inputs or try again.", + type: SnackbarType.error); } } @@ -174,12 +183,10 @@ class _JobDetailsScreenState extends State with UIMixin { final action = job.nextTaggingAction; File? attachmentFile; - // Step 1: Show comment bottom sheet final comment = await showCommentBottomSheet( context, action == 0 ? "Tag In" : "Tag Out"); - if (comment == null) return; // User cancelled + if (comment == null) return; - // Step 2: Ask for optional image await showDialog( context: context, builder: (_) => ConfirmDialog( @@ -199,7 +206,6 @@ class _JobDetailsScreenState extends State with UIMixin { ), ); - // Step 3: Call attendance update await controller.updateJobAttendance( jobId: job.id, action: action == 0 ? 0 : 1, @@ -207,15 +213,14 @@ class _JobDetailsScreenState extends State with UIMixin { 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 + final msg = controller.attendanceMessage.value; + if (msg.toLowerCase().contains("failed") || + msg.toLowerCase().contains("error")) { + showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error); + return; } - // Success - Get.snackbar("Success", controller.attendanceMessage.value); + showAppSnackbar(title: "Success", message: msg, type: SnackbarType.success); } Widget _buildSectionCard({ @@ -454,255 +459,261 @@ 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; - final logs = controller.attendanceLog.value?.data ?? []; + return Obx(() { + final job = controller.jobDetail.value?.data; + final isLoading = controller.isTagging.value; + final action = job?.nextTaggingAction; + final logs = controller.attendanceLog.value?.data ?? []; - if (job == null) return const SizedBox(); + if (job == null) return const SizedBox(); - 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], + 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( + 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: () 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( - 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 ? null : _handleTagAction, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 20), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), + onPressed: isLoading ? null : _handleTagAction, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 20), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + backgroundColor: + action == 0 ? Colors.green : Colors.red, ), - backgroundColor: action == 0 ? Colors.green : Colors.red, ), ), ), - ), - // Attendance Logs - Obx(() { - if (!isAttendanceExpanded.value) return Container(); + // Attendance Logs + Obx(() { + if (!isAttendanceExpanded.value) return Container(); - if (isAttendanceLogLoading.value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Center(child: CircularProgressIndicator()), - ); - } + if (isAttendanceLogLoading.value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center(child: CircularProgressIndicator()), + ); + } - if (logs.isEmpty) { - return Padding( + 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), - child: MyText.bodyMedium( - "No attendance logs available", - color: Colors.grey[600], - ), - ); - } + itemCount: logs.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (_, index) { + final log = logs[index]; + final employeeName = + "${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 ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.only(top: 12), - itemCount: logs.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), - itemBuilder: (_, index) { - final log = logs[index]; - final employeeName = - "${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 Card( - elevation: 1, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8)), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Top Row: Icon, Employee, Date, Time - Row( - children: [ - Icon( - log.action == 0 ? Icons.login : Icons.logout, - color: log.action == 0 - ? Colors.green - : Colors.red, - size: 18, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - employeeName, - style: const TextStyle( - fontWeight: FontWeight.w600), + return Card( + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top Row: Icon, Employee, Date, Time + Row( + children: [ + Icon( + log.action == 0 ? Icons.login : Icons.logout, + color: log.action == 0 + ? Colors.green + : Colors.red, + size: 18, ), - ), - Text( - "$date | $time", - style: TextStyle( - fontSize: 12, color: Colors.grey[700]), - ), - ], - ), - const SizedBox(height: 4), - - // Comment - if (log.comment?.isNotEmpty == true) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - log.comment!, - style: const TextStyle(fontSize: 13), - ), + const SizedBox(width: 6), + Expanded( + child: Text( + employeeName, + style: const TextStyle( + fontWeight: FontWeight.w600), + ), + ), + Text( + "$date | $time", + style: TextStyle( + fontSize: 12, color: Colors.grey[700]), + ), + ], ), + const SizedBox(height: 4), - // 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: Padding( + // Comment + if (log.comment?.isNotEmpty == true) + Padding( padding: const EdgeInsets.only(top: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Icon(Icons.location_on, - size: 14, color: Colors.blue), - SizedBox(width: 4), - Text( - "View Location", - style: TextStyle( - fontSize: 12, - color: Colors.blue, - decoration: TextDecoration.underline), - ), - ], + child: Text( + log.comment!, + style: const TextStyle(fontSize: 13), ), ), - ), - // Attached Image - if (log.document != null) - Padding( - padding: const EdgeInsets.only(top: 4), - child: GestureDetector( - onTap: () => showDialog( - context: context, - builder: (_) => Dialog( + // 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: Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.location_on, + size: 14, color: Colors.blue), + SizedBox(width: 4), + Text( + "View Location", + style: TextStyle( + fontSize: 12, + color: Colors.blue, + decoration: + TextDecoration.underline), + ), + ], + ), + ), + ), + + // Attached Image + if (log.document != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: GestureDetector( + onTap: () => showDialog( + context: context, + builder: (_) => Dialog( + child: Image.network( + log.document!.preSignedUrl, + fit: BoxFit.cover, + height: 250, + errorBuilder: (_, __, ___) => + const Icon( + Icons.broken_image, + size: 50, + color: Colors.grey, + ), + ), + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), child: Image.network( - log.document!.preSignedUrl, + log.document!.thumbPreSignedUrl.isNotEmpty + ? log.document!.thumbPreSignedUrl + : log.document!.preSignedUrl, + height: 50, + width: 50, fit: BoxFit.cover, - height: 250, errorBuilder: (_, __, ___) => const Icon( Icons.broken_image, - size: 50, + size: 40, color: Colors.grey, ), ), ), ), - child: ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Image.network( - log.document!.thumbPreSignedUrl.isNotEmpty - ? log.document!.thumbPreSignedUrl - : log.document!.preSignedUrl, - height: 50, - width: 50, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const Icon( - Icons.broken_image, - size: 40, - color: Colors.grey, - ), - ), - ), ), - ), - ], + ], + ), ), - ), - ); - }, - ); - }), - ], + ); + }, + ); + }), + ], + ), ), - ), - ); - }); -} + ); + }); + } @override Widget build(BuildContext context) {