Offline Support
This commit is contained in:
@@ -41,6 +41,7 @@ import '../../widgets/gemini_button.dart';
|
||||
import '../../widgets/multi_select_picker.dart';
|
||||
import '../../widgets/app_page_header.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/sync_pending_badge.dart';
|
||||
|
||||
class AttendanceScreen extends ConsumerStatefulWidget {
|
||||
const AttendanceScreen({super.key});
|
||||
@@ -362,6 +363,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
final profile = ref.watch(currentProfileProvider).valueOrNull;
|
||||
final schedulesAsync = ref.watch(dutySchedulesProvider);
|
||||
final logsAsync = ref.watch(attendanceLogsProvider);
|
||||
final pendingCheckIns = ref.watch(offlinePendingCheckInsProvider);
|
||||
final allowTracking = profile?.allowTracking ?? false;
|
||||
// local state for optimistic switch update. We only trust `_trackingLocal`
|
||||
// while a save is in flight – after that the server-side value (`allowTracking`)
|
||||
@@ -433,8 +435,12 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
// (Note: this override should not prevent the overtime check-in from being shown.)
|
||||
|
||||
// Show overtime check-in when the user has no schedule today, or their last
|
||||
// scheduled shift has already ended.
|
||||
// scheduled shift has already ended. Guard with hasValue so the card never
|
||||
// flashes during the initial stream loading phase (valueOrNull returns []
|
||||
// while loading, making hasScheduleToday false prematurely).
|
||||
final showOvertimeCard =
|
||||
schedulesAsync.hasValue &&
|
||||
logsAsync.hasValue &&
|
||||
(activeOvertimeLog.isEmpty && _overtimeLogId == null) &&
|
||||
activeLog.isEmpty &&
|
||||
(!hasScheduleToday || hasScheduleEnded);
|
||||
@@ -786,6 +792,15 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (pendingCheckIns.any(
|
||||
(l) => l.dutyScheduleId == schedule.id,
|
||||
)) ...[
|
||||
SyncPendingBadge(
|
||||
isPending: true,
|
||||
compact: true,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
_statusChip(context, statusLabel),
|
||||
],
|
||||
),
|
||||
@@ -4228,7 +4243,11 @@ class _MyScheduleTabState extends ConsumerState<_MyScheduleTab> {
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
if (!alreadyProcessed) {
|
||||
showErrorSnackBar(context, 'Failed: $e');
|
||||
if (isOfflineSaveError(e)) {
|
||||
showSuccessSnackBar(context, 'Response saved offline — will sync when connected.');
|
||||
} else {
|
||||
showErrorSnackBar(context, 'Failed: $e');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Refresh the swap list — the new provider fires an immediate REST poll
|
||||
@@ -4872,6 +4891,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
|
||||
final slipsAsync = ref.watch(passSlipsProvider);
|
||||
final profilesAsync = ref.watch(profilesProvider);
|
||||
final activeSlip = ref.watch(activePassSlipProvider);
|
||||
final pendingSlips = ref.watch(offlinePendingPassSlipsProvider);
|
||||
final isAdmin = profile?.role == 'admin' || profile?.role == 'dispatcher';
|
||||
|
||||
final Map<String, Profile> profileById = {
|
||||
@@ -4985,6 +5005,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
|
||||
slip,
|
||||
profileById,
|
||||
showActions: true,
|
||||
isPending: pendingSlips.any((p) => p.id == slip.id),
|
||||
),
|
||||
),
|
||||
if (slips.where((s) => s.status == 'pending').isEmpty)
|
||||
@@ -5017,6 +5038,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
|
||||
slip,
|
||||
profileById,
|
||||
showActions: false,
|
||||
isPending: pendingSlips.any((p) => p.id == slip.id),
|
||||
),
|
||||
),
|
||||
if (slips.isEmpty)
|
||||
@@ -5044,6 +5066,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
|
||||
PassSlip slip,
|
||||
Map<String, Profile> profileById, {
|
||||
required bool showActions,
|
||||
bool isPending = false,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
@@ -5073,6 +5096,10 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(name, style: theme.textTheme.titleSmall)),
|
||||
if (isPending) ...[
|
||||
SyncPendingBadge(isPending: true, compact: true),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
@@ -5151,7 +5178,11 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, 'Failed: $e');
|
||||
if (isOfflineSaveError(e)) {
|
||||
showSuccessSnackBar(context, 'Approval saved offline — will sync when connected.');
|
||||
} else {
|
||||
showErrorSnackBar(context, 'Failed: $e');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
@@ -5167,7 +5198,11 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, 'Failed: $e');
|
||||
if (isOfflineSaveError(e)) {
|
||||
showSuccessSnackBar(context, 'Rejection saved offline — will sync when connected.');
|
||||
} else {
|
||||
showErrorSnackBar(context, 'Failed: $e');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
@@ -5183,7 +5218,11 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, 'Failed: $e');
|
||||
if (isOfflineSaveError(e)) {
|
||||
showSuccessSnackBar(context, 'Completion saved offline — will sync when connected.');
|
||||
} else {
|
||||
showErrorSnackBar(context, 'Failed: $e');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
@@ -5212,6 +5251,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
|
||||
final profile = ref.watch(currentProfileProvider).valueOrNull;
|
||||
final leavesAsync = ref.watch(leavesProvider);
|
||||
final profilesAsync = ref.watch(profilesProvider);
|
||||
final pendingLeavesList = ref.watch(offlinePendingLeavesProvider);
|
||||
|
||||
if (profile == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
@@ -5259,6 +5299,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
|
||||
leave,
|
||||
profileById,
|
||||
showApproval: true,
|
||||
isPending: pendingLeavesList.any((l) => l.id == leave.id),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
@@ -5292,6 +5333,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
|
||||
leave,
|
||||
profileById,
|
||||
showApproval: false,
|
||||
isPending: pendingLeavesList.any((l) => l.id == leave.id),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5314,6 +5356,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
|
||||
leave,
|
||||
profileById,
|
||||
showApproval: false,
|
||||
isPending: pendingLeavesList.any((l) => l.id == leave.id),
|
||||
),
|
||||
),
|
||||
if (leaves
|
||||
@@ -5343,6 +5386,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
|
||||
LeaveOfAbsence leave,
|
||||
Map<String, Profile> profileById, {
|
||||
required bool showApproval,
|
||||
bool isPending = false,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
@@ -5372,6 +5416,10 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(name, style: theme.textTheme.titleSmall)),
|
||||
if (isPending) ...[
|
||||
SyncPendingBadge(isPending: true, compact: true),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
@@ -5473,7 +5521,11 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, 'Failed: $e');
|
||||
if (isOfflineSaveError(e)) {
|
||||
showSuccessSnackBar(context, 'Approval saved offline — will sync when connected.');
|
||||
} else {
|
||||
showErrorSnackBar(context, 'Failed: $e');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
@@ -5491,7 +5543,11 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, 'Failed: $e');
|
||||
if (isOfflineSaveError(e)) {
|
||||
showSuccessSnackBar(context, 'Rejection saved offline — will sync when connected.');
|
||||
} else {
|
||||
showErrorSnackBar(context, 'Failed: $e');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
@@ -5509,7 +5565,11 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, 'Failed: $e');
|
||||
if (isOfflineSaveError(e)) {
|
||||
showSuccessSnackBar(context, 'Cancellation saved offline — will sync when connected.');
|
||||
} else {
|
||||
showErrorSnackBar(context, 'Failed: $e');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
@@ -5618,7 +5678,14 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> {
|
||||
widget.onSubmitted();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) showErrorSnackBar(context, 'Failed: $e');
|
||||
if (!mounted) return;
|
||||
if (isOfflineSaveError(e)) {
|
||||
Navigator.of(context).pop();
|
||||
showSuccessSnackBarGlobal('Pass slip request saved offline — will sync when connected.');
|
||||
widget.onSubmitted();
|
||||
} else {
|
||||
showErrorSnackBar(context, 'Failed: $e');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
@@ -5893,7 +5960,12 @@ class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> {
|
||||
widget.onSubmitted();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
if (!mounted) return;
|
||||
if (isOfflineSaveError(e)) {
|
||||
Navigator.of(context).pop();
|
||||
showSuccessSnackBarGlobal('Leave request saved offline — will sync when connected.');
|
||||
widget.onSubmitted();
|
||||
} else {
|
||||
showErrorSnackBar(context, 'Failed to file leave: $e');
|
||||
}
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user