feat: integrate snackbar notifications for document and job updates in user document controller and job detail screen

This commit is contained in:
Vaibhav Surve 2025-11-19 17:29:10 +05:30
parent 92c739045c
commit ec6a45ed43
2 changed files with 272 additions and 248 deletions

View File

@ -3,6 +3,7 @@ import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/document/document_filter_model.dart'; import 'package:marco/model/document/document_filter_model.dart';
import 'package:marco/model/document/documents_list_model.dart'; import 'package:marco/model/document/documents_list_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class DocumentController extends GetxController { class DocumentController extends GetxController {
// ==================== Observables ==================== // ==================== Observables ====================
@ -38,7 +39,6 @@ class DocumentController extends GetxController {
final endDate = Rxn<DateTime>(); final endDate = Rxn<DateTime>();
// ==================== Lifecycle ==================== // ==================== Lifecycle ====================
@override @override
void onClose() { void onClose() {
// Don't dispose searchController here - it's managed by the page // Don't dispose searchController here - it's managed by the page
@ -87,13 +87,23 @@ class DocumentController extends GetxController {
entityId: entityId, entityId: entityId,
reset: true, reset: true,
); );
// Show success snackbar
showAppSnackbar(
title: 'Success',
message: isActive ? 'Document deactivated' : 'Document activated',
type: SnackbarType.success,
);
return true; return true;
} else { } else {
errorMessage.value = 'Failed to update document state'; errorMessage.value = 'Failed to update document state';
_showError('Failed to update document state');
return false; return false;
} }
} catch (e) { } catch (e) {
errorMessage.value = 'Error updating document: $e'; errorMessage.value = 'Error updating document: $e';
_showError('Error updating document: $e');
debugPrint('❌ Error toggling document state: $e'); debugPrint('❌ Error toggling document state: $e');
return false; return false;
} finally { } finally {
@ -110,17 +120,13 @@ class DocumentController extends GetxController {
bool reset = false, bool reset = false,
}) async { }) async {
try { try {
// Reset pagination if needed
if (reset) { if (reset) {
pageNumber.value = 1; pageNumber.value = 1;
documents.clear(); documents.clear();
hasMore.value = true; hasMore.value = true;
} }
// Don't fetch if no more data
if (!hasMore.value && !reset) return; if (!hasMore.value && !reset) return;
// Prevent duplicate requests
if (isLoading.value) return; if (isLoading.value) return;
isLoading.value = true; isLoading.value = true;
@ -147,12 +153,24 @@ class DocumentController extends GetxController {
errorMessage.value = response?.message ?? 'Failed to fetch documents'; errorMessage.value = response?.message ?? 'Failed to fetch documents';
if (documents.isEmpty) { if (documents.isEmpty) {
_showError('Failed to load documents'); _showError('Failed to load documents');
} else {
showAppSnackbar(
title: 'Warning',
message: 'No more documents to load',
type: SnackbarType.warning,
);
} }
} }
} catch (e) { } catch (e) {
errorMessage.value = 'Error fetching documents: $e'; errorMessage.value = 'Error fetching documents: $e';
if (documents.isEmpty) { if (documents.isEmpty) {
_showError('Error loading documents'); _showError('Error loading documents');
} else {
showAppSnackbar(
title: 'Error',
message: 'Error fetching additional documents',
type: SnackbarType.error,
);
} }
debugPrint('❌ Error fetching documents: $e'); debugPrint('❌ Error fetching documents: $e');
} finally { } finally {
@ -185,17 +203,12 @@ class DocumentController extends GetxController {
isVerified.value != null; isVerified.value != null;
} }
/// Show error message /// Show error message via snackbar
void _showError(String message) { void _showError(String message) {
Get.snackbar( showAppSnackbar(
'Error', title: 'Error',
message, message: message,
snackPosition: SnackPosition.BOTTOM, type: SnackbarType.error,
backgroundColor: Colors.red.shade100,
colorText: Colors.red.shade900,
margin: const EdgeInsets.all(16),
borderRadius: 8,
duration: const Duration(seconds: 3),
); );
} }

View File

@ -17,6 +17,7 @@ import 'package:image_picker/image_picker.dart';
import 'dart:io'; import 'dart:io';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class JobDetailsScreen extends StatefulWidget { class JobDetailsScreen extends StatefulWidget {
final String jobId; final String jobId;
@ -128,7 +129,6 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
{"op": "replace", "path": "/assignees", "value": assigneesPayload}); {"op": "replace", "path": "/assignees", "value": assigneesPayload});
final originalTags = job.tags; final originalTags = job.tags;
final replaceTagsPayload = originalTags.map((t) { final replaceTagsPayload = originalTags.map((t) {
final isSelected = _selectedTags.any((s) => s.id == t.id); final isSelected = _selectedTags.any((s) => s.id == t.id);
return {"id": t.id, "name": t.name, "isActive": isSelected}; return {"id": t.id, "name": t.name, "isActive": isSelected};
@ -149,7 +149,10 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
} }
if (operations.isEmpty) { if (operations.isEmpty) {
Get.snackbar("Info", "No changes detected to save."); showAppSnackbar(
title: "Info",
message: "No changes detected to save.",
type: SnackbarType.info);
return; return;
} }
@ -159,11 +162,17 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
); );
if (success) { if (success) {
Get.snackbar("Success", "Job updated successfully"); showAppSnackbar(
title: "Success",
message: "Job updated successfully",
type: SnackbarType.success);
await controller.fetchJobDetail(widget.jobId); await controller.fetchJobDetail(widget.jobId);
isEditing.value = false; isEditing.value = false;
} else { } 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<JobDetailsScreen> with UIMixin {
final action = job.nextTaggingAction; final action = job.nextTaggingAction;
File? attachmentFile; File? attachmentFile;
// 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 if (comment == null) return;
// Step 2: Ask for optional image
await showDialog( await showDialog(
context: context, context: context,
builder: (_) => ConfirmDialog( builder: (_) => ConfirmDialog(
@ -199,7 +206,6 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
), ),
); );
// 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,
@ -207,15 +213,14 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
attachment: attachmentFile, attachment: attachmentFile,
); );
// Step 4: Check message to detect failure final msg = controller.attendanceMessage.value;
if (controller.attendanceMessage.value.toLowerCase().contains("failed") || if (msg.toLowerCase().contains("failed") ||
controller.attendanceMessage.value.toLowerCase().contains("error")) { msg.toLowerCase().contains("error")) {
Get.snackbar("Error", controller.attendanceMessage.value); showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error);
return; // Do NOT close bottom sheet return;
} }
// Success showAppSnackbar(title: "Success", message: msg, type: SnackbarType.success);
Get.snackbar("Success", controller.attendanceMessage.value);
} }
Widget _buildSectionCard({ Widget _buildSectionCard({
@ -454,255 +459,261 @@ 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; final action = job?.nextTaggingAction;
final logs = controller.attendanceLog.value?.data ?? []; final logs = controller.attendanceLog.value?.data ?? [];
if (job == null) return const SizedBox(); if (job == null) return const SizedBox();
return Card( return Card(
elevation: 3, elevation: 3,
shadowColor: Colors.black12, shadowColor: Colors.black12,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
margin: const EdgeInsets.symmetric(vertical: 8), margin: const EdgeInsets.symmetric(vertical: 8),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Header // Header
Row( Row(
children: [ children: [
Icon(Icons.access_time_outlined, Icon(Icons.access_time_outlined,
size: 20, color: Colors.blueAccent), size: 20, color: Colors.blueAccent),
const SizedBox(width: 8), const SizedBox(width: 8),
MyText.bodyLarge("Attendance", fontWeight: 700, fontSize: 16), MyText.bodyLarge("Attendance", fontWeight: 700, fontSize: 16),
const Spacer(), const Spacer(),
Obx(() => IconButton( Obx(() => IconButton(
icon: Icon( icon: Icon(
isAttendanceExpanded.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 {
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 { onPressed: isLoading ? null : _handleTagAction,
isAttendanceExpanded.value = !isAttendanceExpanded.value; style: ElevatedButton.styleFrom(
if (isAttendanceExpanded.value && job != null) { padding: const EdgeInsets.symmetric(horizontal: 20),
await controller.fetchJobAttendanceLog( shape: RoundedRectangleBorder(
job.attendanceId ?? ''); borderRadius: BorderRadius.circular(5),
} ),
}, backgroundColor:
)) action == 0 ? Colors.green : Colors.red,
],
),
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),
), ),
backgroundColor: action == 0 ? Colors.green : Colors.red,
), ),
), ),
), ),
),
// Attendance Logs // Attendance Logs
Obx(() { Obx(() {
if (!isAttendanceExpanded.value) return Container(); if (!isAttendanceExpanded.value) return Container();
if (isAttendanceLogLoading.value) { if (isAttendanceLogLoading.value) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
); );
} }
if (logs.isEmpty) { if (logs.isEmpty) {
return Padding( 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), padding: const EdgeInsets.only(top: 12),
child: MyText.bodyMedium( itemCount: logs.length,
"No attendance logs available", separatorBuilder: (_, __) => const SizedBox(height: 8),
color: Colors.grey[600], 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( return Card(
shrinkWrap: true, elevation: 1,
physics: const NeverScrollableScrollPhysics(), shape: RoundedRectangleBorder(
padding: const EdgeInsets.only(top: 12), borderRadius: BorderRadius.circular(8)),
itemCount: logs.length, child: Padding(
separatorBuilder: (_, __) => const SizedBox(height: 8), padding: const EdgeInsets.symmetric(
itemBuilder: (_, index) { horizontal: 12, vertical: 8),
final log = logs[index]; child: Column(
final employeeName = crossAxisAlignment: CrossAxisAlignment.start,
"${log.employee.firstName} ${log.employee.lastName}"; children: [
final date = // Top Row: Icon, Employee, Date, Time
DateTimeUtils.convertUtcToLocal(log.markedAt.toIso8601String(), Row(
format: 'd MMM yyyy'); children: [
final time = Icon(
DateTimeUtils.convertUtcToLocal(log.markedAt.toIso8601String(), log.action == 0 ? Icons.login : Icons.logout,
format: 'hh:mm a'); color: log.action == 0
? Colors.green
return Card( : Colors.red,
elevation: 1, size: 18,
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),
), ),
), const SizedBox(width: 6),
Text( Expanded(
"$date | $time", child: Text(
style: TextStyle( employeeName,
fontSize: 12, color: Colors.grey[700]), style: const TextStyle(
), fontWeight: FontWeight.w600),
], ),
), ),
const SizedBox(height: 4), Text(
"$date | $time",
// Comment style: TextStyle(
if (log.comment?.isNotEmpty == true) fontSize: 12, color: Colors.grey[700]),
Padding( ),
padding: const EdgeInsets.only(top: 4), ],
child: Text(
log.comment!,
style: const TextStyle(fontSize: 13),
),
), ),
const SizedBox(height: 4),
// Location // Comment
if (log.latitude != null && log.longitude != null) if (log.comment?.isNotEmpty == true)
GestureDetector( Padding(
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), padding: const EdgeInsets.only(top: 4),
child: Row( child: Text(
mainAxisSize: MainAxisSize.min, log.comment!,
children: const [ style: const TextStyle(fontSize: 13),
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 // Location
if (log.document != null) if (log.latitude != null && log.longitude != null)
Padding( GestureDetector(
padding: const EdgeInsets.only(top: 4), onTap: () async {
child: GestureDetector( final lat =
onTap: () => showDialog( double.tryParse(log.latitude!) ?? 0.0;
context: context, final lon =
builder: (_) => Dialog( 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( child: Image.network(
log.document!.preSignedUrl, log.document!.thumbPreSignedUrl.isNotEmpty
? log.document!.thumbPreSignedUrl
: log.document!.preSignedUrl,
height: 50,
width: 50,
fit: BoxFit.cover, fit: BoxFit.cover,
height: 250,
errorBuilder: (_, __, ___) => const Icon( errorBuilder: (_, __, ___) => const Icon(
Icons.broken_image, Icons.broken_image,
size: 50, size: 40,
color: Colors.grey, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {