Offline Support

This commit is contained in:
2026-04-27 06:20:39 +08:00
parent 7d8851a94a
commit 48cd3bcd60
121 changed files with 14798 additions and 2214 deletions
+82 -10
View File
@@ -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 {