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/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<DateTime>();
// ==================== 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,
);
}

View File

@ -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<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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) {