From 858520bd8d6f423c19a633130a45f429bcb3579f Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sun, 3 May 2026 14:50:14 +0800 Subject: [PATCH] Offline rediness --- lib/brick/cache_warmer.dart | 204 ++++---- lib/providers/attendance_provider.dart | 40 +- lib/providers/notifications_provider.dart | 6 + lib/providers/offline_readiness_provider.dart | 47 ++ lib/providers/sync_queue_provider.dart | 70 +++ lib/providers/teams_provider.dart | 13 +- lib/providers/tickets_provider.dart | 1 - lib/providers/whereabouts_provider.dart | 1 + lib/screens/tasks/tasks_list_screen.dart | 5 +- lib/screens/tickets/tickets_list_screen.dart | 5 +- lib/widgets/app_shell.dart | 14 + lib/widgets/offline_banner.dart | 202 +++++--- lib/widgets/offline_readiness_sheet.dart | 490 ++++++++++++++++++ 13 files changed, 893 insertions(+), 205 deletions(-) create mode 100644 lib/providers/offline_readiness_provider.dart create mode 100644 lib/widgets/offline_readiness_sheet.dart diff --git a/lib/brick/cache_warmer.dart b/lib/brick/cache_warmer.dart index bf9ddb94..63c08e4e 100644 --- a/lib/brick/cache_warmer.dart +++ b/lib/brick/cache_warmer.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:convert'; -import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; +import 'package:flutter/foundation.dart' show ValueNotifier, debugPrint, kIsWeb; import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -45,6 +45,9 @@ class CacheWarmer { static const _kTeamMembersKey = 'cache_team_members_json'; static const _kUserOfficesKey = 'cache_user_offices_json'; + /// Prefix for per-table row-count stamps written after each successful warm. + static const _kCountPrefix = 'cache_count_'; + /// Foreground timer handle so [start]/[stop] is idempotent. static Timer? _refreshTimer; @@ -52,6 +55,9 @@ class CacheWarmer { static bool _hasWarmedOnce = false; static bool get hasWarmedOnce => _hasWarmedOnce; + /// Fires true when a warm starts, false when it completes. + static final ValueNotifier warmingNotifier = ValueNotifier(false); + /// Single-flight guard to prevent overlapping warms. static Future? _inflight; @@ -62,9 +68,21 @@ class CacheWarmer { final existing = _inflight; if (existing != null) return existing; + warmingNotifier.value = true; final future = _runWarmAll(client); _inflight = future; - return future.whenComplete(() => _inflight = null); + // Guarantee the warming flag is cleared within 90 s even if an HTTP request + // hangs (no per-request timeout on the Supabase client by default). + return future + .timeout( + const Duration(seconds: 90), + onTimeout: () => + debugPrint('[CacheWarmer] warmAll timeout — clearing warming flag'), + ) + .whenComplete(() { + _inflight = null; + warmingNotifier.value = false; + }); } static Future _runWarmAll(SupabaseClient client) async { @@ -121,24 +139,28 @@ class CacheWarmer { for (final row in rows) { await _localUpsert(Office.fromMap(row)); } + await _writeCount('offices', rows.length); }), _safe('services', () async { final rows = await client.from('services').select(); for (final row in rows) { await _localUpsert(Service.fromMap(row)); } + await _writeCount('services', rows.length); }), _safe('profiles', () async { final rows = await client.from('profiles').select(); for (final row in rows) { await _localUpsert(Profile.fromMap(row)); } + await _writeCount('profiles', rows.length); }), _safe('teams', () async { final rows = await client.from('teams').select(); for (final row in rows) { await _localUpsert(Team.fromMap(row)); } + await _writeCount('teams', rows.length); }), // Association tables have composite PKs, so we cache them as JSON in // SharedPreferences instead of Brick. The list is small and read-only. @@ -146,6 +168,7 @@ class CacheWarmer { final rows = await client.from('team_members').select(); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kTeamMembersKey, jsonEncode(rows)); + await _writeCount('team_members', rows.length); }), _safe('user_offices', () async { final rows = await client.from('user_offices').select(); @@ -189,147 +212,77 @@ class CacheWarmer { // ------------------------------------------------------------------ static Future _warmTasks(SupabaseClient client, String sinceIso) async { - final rows = await client - .from('tasks') - .select() - .gte('created_at', sinceIso); - for (final row in rows) { - await _localUpsert(Task.fromMap(row)); - } + final rows = await client.from('tasks').select().gte('created_at', sinceIso); + for (final row in rows) { await _localUpsert(Task.fromMap(row)); } + await _writeCount('tasks', rows.length); } - static Future _warmTickets( - SupabaseClient client, - String sinceIso, - ) async { - final rows = await client - .from('tickets') - .select() - .gte('created_at', sinceIso); - for (final row in rows) { - await _localUpsert(Ticket.fromMap(row)); - } + static Future _warmTickets(SupabaseClient client, String sinceIso) async { + final rows = await client.from('tickets').select().gte('created_at', sinceIso); + for (final row in rows) { await _localUpsert(Ticket.fromMap(row)); } + await _writeCount('tickets', rows.length); } - static Future _warmItServiceRequests( - SupabaseClient client, - String sinceIso, - ) async { - final rows = await client - .from('it_service_requests') - .select() - .gte('created_at', sinceIso); - for (final row in rows) { - await _localUpsert(ItServiceRequest.fromMap(row)); - } + static Future _warmItServiceRequests(SupabaseClient client, String sinceIso) async { + final rows = await client.from('it_service_requests').select().gte('created_at', sinceIso); + for (final row in rows) { await _localUpsert(ItServiceRequest.fromMap(row)); } + await _writeCount('it_service_requests', rows.length); } - static Future _warmAnnouncements( - SupabaseClient client, - String sinceIso, - ) async { - final rows = await client - .from('announcements') - .select() - .gte('created_at', sinceIso); - for (final row in rows) { - await _localUpsert(Announcement.fromMap(row)); - } - // Also pull comments for the windowed announcements so detail screens have - // them offline. + static Future _warmAnnouncements(SupabaseClient client, String sinceIso) async { + final rows = await client.from('announcements').select().gte('created_at', sinceIso); + for (final row in rows) { await _localUpsert(Announcement.fromMap(row)); } + await _writeCount('announcements', rows.length); + // Also pull comments so detail screens have them offline. try { final commentRows = await client .from('announcement_comments') .select() .gte('created_at', sinceIso); for (final row in commentRows) { - await _localUpsert( - AnnouncementComment.fromMap(row), - ); + await _localUpsert(AnnouncementComment.fromMap(row)); } } catch (e) { debugPrint('[CacheWarmer] announcement_comments failed: $e'); } } - static Future _warmAttendance( - SupabaseClient client, - String sinceIso, - ) async { - final rows = await client - .from('attendance_logs') - .select() - .gte('check_in_at', sinceIso); - for (final row in rows) { - await _localUpsert(AttendanceLog.fromMap(row)); - } + static Future _warmAttendance(SupabaseClient client, String sinceIso) async { + final rows = await client.from('attendance_logs').select().gte('check_in_at', sinceIso); + for (final row in rows) { await _localUpsert(AttendanceLog.fromMap(row)); } + await _writeCount('attendance_logs', rows.length); } - static Future _warmPassSlips( - SupabaseClient client, - String sinceIso, - ) async { - final rows = await client - .from('pass_slips') - .select() - .gte('created_at', sinceIso); - for (final row in rows) { - await _localUpsert(PassSlip.fromMap(row)); - } + static Future _warmPassSlips(SupabaseClient client, String sinceIso) async { + final rows = await client.from('pass_slips').select().gte('created_at', sinceIso); + for (final row in rows) { await _localUpsert(PassSlip.fromMap(row)); } + await _writeCount('pass_slips', rows.length); } - static Future _warmLeaves( - SupabaseClient client, - String sinceIso, - ) async { - final rows = await client - .from('leave_of_absence') - .select() - .gte('created_at', sinceIso); - for (final row in rows) { - await _localUpsert(LeaveOfAbsence.fromMap(row)); - } + static Future _warmLeaves(SupabaseClient client, String sinceIso) async { + final rows = await client.from('leave_of_absence').select().gte('created_at', sinceIso); + for (final row in rows) { await _localUpsert(LeaveOfAbsence.fromMap(row)); } + await _writeCount('leave_of_absence', rows.length); } - static Future _warmDutySchedules( - SupabaseClient client, - String sinceIso, - ) async { + static Future _warmDutySchedules(SupabaseClient client, String sinceIso) async { // Duty schedules are upcoming-focused; pull a wider window so the user // can see today + the next several weeks while offline. - final rows = await client - .from('duty_schedules') - .select() - .gte('start_time', sinceIso); - for (final row in rows) { - await _localUpsert(DutySchedule.fromMap(row)); - } + final rows = await client.from('duty_schedules').select().gte('start_time', sinceIso); + for (final row in rows) { await _localUpsert(DutySchedule.fromMap(row)); } + await _writeCount('duty_schedules', rows.length); } - static Future _warmSwapRequests( - SupabaseClient client, - String sinceIso, - ) async { - final rows = await client - .from('swap_requests') - .select() - .gte('created_at', sinceIso); - for (final row in rows) { - await _localUpsert(SwapRequest.fromMap(row)); - } + static Future _warmSwapRequests(SupabaseClient client, String sinceIso) async { + final rows = await client.from('swap_requests').select().gte('created_at', sinceIso); + for (final row in rows) { await _localUpsert(SwapRequest.fromMap(row)); } + await _writeCount('swap_requests', rows.length); } - static Future _warmNotifications( - SupabaseClient client, - String sinceIso, - ) async { - final rows = await client - .from('notifications') - .select() - .gte('created_at', sinceIso); - for (final row in rows) { - await _localUpsert(NotificationItem.fromMap(row)); - } + static Future _warmNotifications(SupabaseClient client, String sinceIso) async { + final rows = await client.from('notifications').select().gte('created_at', sinceIso); + for (final row in rows) { await _localUpsert(NotificationItem.fromMap(row)); } + await _writeCount('notifications', rows.length); } // ------------------------------------------------------------------ @@ -357,4 +310,29 @@ class CacheWarmer { }()); } } + + /// Persist the number of rows cached for [table] to SharedPreferences. + /// Called at the end of each per-table warm so the Offline Readiness screen + /// can display cache coverage without issuing SQLite count queries. + static Future _writeCount(String table, int count) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('$_kCountPrefix$table', count); + } catch (_) {} + } + + /// Returns cached row counts written by the last successful warm, keyed by + /// table name. A value of -1 means the table has never been warmed. + static Future> readCacheCounts() async { + final prefs = await SharedPreferences.getInstance(); + const tables = [ + 'tasks', 'tickets', 'announcements', 'attendance_logs', + 'duty_schedules', 'pass_slips', 'leave_of_absence', 'profiles', + 'teams', 'team_members', 'it_service_requests', 'notifications', + 'swap_requests', 'offices', 'services', + ]; + return { + for (final t in tables) t: prefs.getInt('$_kCountPrefix$t') ?? -1, + }; + } } diff --git a/lib/providers/attendance_provider.dart b/lib/providers/attendance_provider.dart index 8349edef..d1524ac8 100644 --- a/lib/providers/attendance_provider.dart +++ b/lib/providers/attendance_provider.dart @@ -175,19 +175,55 @@ final attendanceLogsProvider = StreamProvider>((ref) { for (final params in pendingRaw) { final localId = params['localId'] as String; try { - await client.rpc( + final serverId = await client.rpc( 'attendance_check_in', params: { 'p_duty_id': params['p_duty_id'], 'p_lat': params['p_lat'], 'p_lng': params['p_lng'], }, - ); + ) as String?; syncedLocalIds.add(localId); + // Re-key any pending verification update from local UUID → server UUID + // so the PATCH in the updates replay hits the real attendance_logs row. + if (serverId != null) { + final updates = ref.read(offlinePendingAttendanceUpdatesProvider); + if (updates.containsKey(localId)) { + final updated = Map>.from(updates); + updated[serverId] = updated.remove(localId)!; + ref + .read(offlinePendingAttendanceUpdatesProvider.notifier) + .state = updated; + } + } } catch (e) { final msg = e.toString(); if (msg.contains('23505') || msg.contains('duplicate key')) { syncedLocalIds.add(localId); + // Duplicate key means the log already exists on the server. + // Look up its real ID so any pending verification photo can be re-keyed. + try { + final existing = await client + .from('attendance_logs') + .select('id') + .eq('duty_schedule_id', params['p_duty_id'] as String) + .order('check_in_at', ascending: false) + .limit(1) + .maybeSingle(); + final serverId = existing?['id'] as String?; + if (serverId != null) { + final updates = + ref.read(offlinePendingAttendanceUpdatesProvider); + if (updates.containsKey(localId)) { + final updated = + Map>.from(updates); + updated[serverId] = updated.remove(localId)!; + ref + .read(offlinePendingAttendanceUpdatesProvider.notifier) + .state = updated; + } + } + } catch (_) {} } else { debugPrint( '[attendanceLogsProvider] failed to sync check-in localId=$localId: $e', diff --git a/lib/providers/notifications_provider.dart b/lib/providers/notifications_provider.dart index 6e4404ab..8e42b773 100644 --- a/lib/providers/notifications_provider.dart +++ b/lib/providers/notifications_provider.dart @@ -419,6 +419,12 @@ class NotificationsController { final current = List>.from( ref.read(offlinePendingBatchNotificationReadsProvider), ); + // Idempotency: skip if same (filter_type, filter_id) is already queued. + // Mark-read is idempotent — queuing duplicates only inflates the banner count. + if (current.any((e) => + e['filter_type'] == filterType && e['filter_id'] == filterId)) { + return; + } current.add({ 'filter_type': filterType, 'filter_id': filterId, diff --git a/lib/providers/offline_readiness_provider.dart b/lib/providers/offline_readiness_provider.dart new file mode 100644 index 00000000..1d459027 --- /dev/null +++ b/lib/providers/offline_readiness_provider.dart @@ -0,0 +1,47 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../brick/cache_warmer.dart'; +import 'attendance_provider.dart'; + +class OfflineReadinessState { + final bool hasWarmedOnce; + final Map cacheCounts; + final int pendingCheckIns; + final int pendingUpdates; + final int pendingPhotos; + + const OfflineReadinessState({ + required this.hasWarmedOnce, + required this.cacheCounts, + required this.pendingCheckIns, + required this.pendingUpdates, + required this.pendingPhotos, + }); + + bool get isReady { + if (!hasWarmedOnce) return false; + const critical = ['tasks', 'profiles', 'duty_schedules', 'attendance_logs']; + return critical.every((t) => (cacheCounts[t] ?? -1) >= 0); + } + + int get totalPending => pendingCheckIns + pendingUpdates; +} + +final offlineReadinessProvider = + FutureProvider.autoDispose((ref) async { + final counts = await CacheWarmer.readCacheCounts(); + final pendingCheckIns = ref.watch(offlinePendingCheckInsProvider).length; + final pendingUpdatesMap = ref.watch(offlinePendingAttendanceUpdatesProvider); + final pendingUpdates = pendingUpdatesMap.length; + final pendingPhotos = pendingUpdatesMap.values + .where((f) => f.containsKey('_local_photo_path')) + .length; + + return OfflineReadinessState( + hasWarmedOnce: CacheWarmer.hasWarmedOnce, + cacheCounts: counts, + pendingCheckIns: pendingCheckIns, + pendingUpdates: pendingUpdates, + pendingPhotos: pendingPhotos, + ); +}); diff --git a/lib/providers/sync_queue_provider.dart b/lib/providers/sync_queue_provider.dart index 00ea69a8..178951eb 100644 --- a/lib/providers/sync_queue_provider.dart +++ b/lib/providers/sync_queue_provider.dart @@ -105,3 +105,73 @@ final pendingSyncCountProvider = Provider((ref) { officeOps + chatMessages; }); + +/// Per-category breakdown of pending-sync items (only non-zero entries). +/// Keys are stable category identifiers used for icon/label lookup in the UI. +final pendingSyncBreakdownProvider = Provider>((ref) { + final result = {}; + + final tasks = ref.watch(offlinePendingTasksProvider).length + + ref.watch(offlinePendingTaskUpdatesProvider).length; + if (tasks > 0) result['tasks'] = tasks; + + final tickets = ref.watch(offlinePendingTicketsProvider).length + + ref.watch(offlinePendingTicketUpdatesProvider).length; + if (tickets > 0) result['tickets'] = tickets; + + final messages = ref + .watch(offlinePendingMessagesProvider) + .values + .fold(0, (s, l) => s + l.length) + + ref + .watch(offlinePendingChatMessagesProvider) + .values + .fold(0, (s, l) => s + l.length); + if (messages > 0) result['messages'] = messages; + + final assignments = ref.watch(offlinePendingAssignmentsProvider).length; + if (assignments > 0) result['assignments'] = assignments; + + final activityLogs = + ref.watch(offlinePendingActivityLogItemsProvider).length; + if (activityLogs > 0) result['activity_logs'] = activityLogs; + + final checkIns = ref.watch(offlinePendingCheckInsProvider).length; + if (checkIns > 0) result['check_ins'] = checkIns; + + final attendanceUpdates = + ref.watch(offlinePendingAttendanceUpdatesProvider).length; + if (attendanceUpdates > 0) result['attendance'] = attendanceUpdates; + + final leaves = ref.watch(offlinePendingLeavesProvider).length + + ref.watch(offlinePendingLeaveUpdatesProvider).length; + if (leaves > 0) result['leaves'] = leaves; + + final passSlips = ref.watch(offlinePendingPassSlipsProvider).length + + ref.watch(offlinePendingPassSlipUpdatesProvider).length; + if (passSlips > 0) result['pass_slips'] = passSlips; + + final announcements = ref.watch(offlinePendingAnnouncementsProvider).length + + ref.watch(offlinePendingAnnouncementUpdatesProvider).length + + ref.watch(offlinePendingAnnouncementCommentsProvider).length; + if (announcements > 0) result['announcements'] = announcements; + + final itRequests = ref.watch(offlinePendingItServiceRequestsProvider).length + + ref.watch(offlinePendingItServiceRequestUpdatesProvider).length + + ref.watch(offlinePendingIsrActionsProvider).length; + if (itRequests > 0) result['it_requests'] = itRequests; + + final notifReads = ref.watch(offlinePendingNotificationReadsProvider).length + + ref.watch(offlinePendingBatchNotificationReadsProvider).length; + if (notifReads > 0) result['notification_reads'] = notifReads; + + final profileUpdates = + ref.watch(offlinePendingProfileUpdatesProvider).length; + if (profileUpdates > 0) result['profile_updates'] = profileUpdates; + + final officeChanges = ref.watch(offlinePendingOfficesProvider).length + + ref.watch(offlinePendingUserOfficeOpsProvider).length; + if (officeChanges > 0) result['office_changes'] = officeChanges; + + return result; +}); diff --git a/lib/providers/teams_provider.dart b/lib/providers/teams_provider.dart index a24a02c0..e21d5558 100644 --- a/lib/providers/teams_provider.dart +++ b/lib/providers/teams_provider.dart @@ -1,10 +1,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'supabase_provider.dart'; -import 'stream_recovery.dart'; -import 'realtime_controller.dart'; +import '../brick/cache_helpers.dart'; +import '../brick/cache_warmer.dart'; import '../models/team.dart'; import '../models/team_member.dart'; +import 'realtime_controller.dart'; +import 'stream_recovery.dart'; +import 'supabase_provider.dart'; /// Real-time stream of teams with automatic recovery and graceful degradation. final teamsProvider = StreamProvider>((ref) { @@ -19,6 +21,7 @@ final teamsProvider = StreamProvider>((ref) { fromMap: Team.fromMap, channelName: 'teams', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async => cachedListFromBrick(), ); ref.onDispose(wrapper.dispose); @@ -40,6 +43,10 @@ final teamMembersProvider = StreamProvider>((ref) { fromMap: TeamMember.fromMap, channelName: 'team_members', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async { + final rows = await CacheWarmer.readCachedTeamMembers(); + return rows.map(TeamMember.fromMap).toList(); + }, ); ref.onDispose(wrapper.dispose); diff --git a/lib/providers/tickets_provider.dart b/lib/providers/tickets_provider.dart index c7d90f25..95949420 100644 --- a/lib/providers/tickets_provider.dart +++ b/lib/providers/tickets_provider.dart @@ -30,7 +30,6 @@ final officesProvider = StreamProvider>((ref) { final pendingOffices = ref.watch(offlinePendingOfficesProvider); List applyPending(List rows) { - if (pendingOffices.isEmpty) return rows; final byId = {for (final r in rows) r.id: r}; for (final p in pendingOffices) { byId.putIfAbsent(p.id, () => p); diff --git a/lib/providers/whereabouts_provider.dart b/lib/providers/whereabouts_provider.dart index 5410f8e7..75481126 100644 --- a/lib/providers/whereabouts_provider.dart +++ b/lib/providers/whereabouts_provider.dart @@ -25,6 +25,7 @@ final livePositionsProvider = StreamProvider>((ref) { fromMap: LivePosition.fromMap, channelName: 'live_positions', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + onOfflineData: () async => const [], ); ref.onDispose(wrapper.dispose); diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index f7a3b0e4..b33d485c 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -961,7 +961,10 @@ class _TasksListScreenState extends ConsumerState b.name.toLowerCase(), ), ); - selectedOfficeId ??= officesSorted.first.id; + if (selectedOfficeId == null || + !officesSorted.any((o) => o.id == selectedOfficeId)) { + selectedOfficeId = officesSorted.first.id; + } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/screens/tickets/tickets_list_screen.dart b/lib/screens/tickets/tickets_list_screen.dart index ce7b25ec..5e461873 100644 --- a/lib/screens/tickets/tickets_list_screen.dart +++ b/lib/screens/tickets/tickets_list_screen.dart @@ -471,7 +471,10 @@ class _TicketsListScreenState extends ConsumerState { if (offices.isEmpty) return const Text('No offices assigned.'); final officesSorted = List.from(offices) ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); - selectedOffice ??= officesSorted.first; + if (selectedOffice == null || + !officesSorted.any((o) => o.id == selectedOffice!.id)) { + selectedOffice = officesSorted.first; + } return DropdownButtonFormField( key: ValueKey(selectedOffice?.id), initialValue: selectedOffice, diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 512c6704..63d01724 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -10,6 +10,7 @@ import '../providers/notifications_provider.dart'; import '../providers/profile_provider.dart'; import 'announcement_banner.dart'; import 'app_breakpoints.dart'; +import 'offline_readiness_sheet.dart'; import 'profile_avatar.dart'; import 'pass_slip_countdown_banner.dart'; import 'shift_countdown_banner.dart'; @@ -600,6 +601,7 @@ Future _showOverflowSheet( List items, VoidCallback onLogout, ) async { + final parentContext = context; await m3ShowBottomSheet( context: context, builder: (context) { @@ -616,6 +618,18 @@ Future _showOverflowSheet( item.onTap(context, onLogout: onLogout); }, ), + if (!kIsWeb) ...[ + const Divider(height: 16), + ListTile( + leading: const Icon(Icons.offline_bolt_rounded), + title: const Text('Offline Readiness'), + subtitle: const Text('Cache status & pending sync'), + onTap: () { + Navigator.of(context).pop(); + showOfflineReadinessSheet(parentContext); + }, + ), + ], ], ), ); diff --git a/lib/widgets/offline_banner.dart b/lib/widgets/offline_banner.dart index e8831034..7de49a14 100644 --- a/lib/widgets/offline_banner.dart +++ b/lib/widgets/offline_banner.dart @@ -2,8 +2,10 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../brick/cache_warmer.dart'; import '../providers/connectivity_provider.dart'; import '../providers/sync_queue_provider.dart'; +import 'offline_readiness_sheet.dart'; /// Wraps [child] with a polished M3 status banner driven by connectivity + sync state. /// @@ -23,57 +25,78 @@ class OfflineBanner extends ConsumerWidget { final pendingCount = ref.watch(pendingSyncCountProvider); final scheme = Theme.of(context).colorScheme; - Widget? banner; + return ValueListenableBuilder( + valueListenable: CacheWarmer.warmingNotifier, + builder: (context, isWarming, _) { + Widget? banner; - if (!isOnline) { - final label = pendingCount > 0 - ? 'Offline — $pendingCount item${pendingCount == 1 ? '' : 's'} queued' - : kIsWeb ? 'No internet connection' : 'No internet — changes saved locally'; - banner = _BannerStrip( - key: const ValueKey('offline'), - backgroundColor: scheme.errorContainer, - foregroundColor: scheme.onErrorContainer, - icon: Icon(Icons.wifi_off_rounded, color: scheme.onErrorContainer, size: 18), - label: label, - showProgress: false, - ); - } else if (pendingCount > 0) { - final label = 'Syncing $pendingCount item${pendingCount == 1 ? '' : 's'}…'; - banner = _BannerStrip( - key: const ValueKey('syncing'), - backgroundColor: scheme.tertiaryContainer, - foregroundColor: scheme.onTertiaryContainer, - icon: _SpinningIcon( - icon: Icons.sync_rounded, - color: scheme.onTertiaryContainer, - size: 18, - ), - label: label, - showProgress: true, - ); - } + if (!isOnline) { + final label = pendingCount > 0 + ? 'Offline — $pendingCount item${pendingCount == 1 ? '' : 's'} queued' + : kIsWeb ? 'No internet connection' : 'No internet — changes saved locally'; + banner = _BannerStrip( + key: const ValueKey('offline'), + backgroundColor: scheme.errorContainer, + foregroundColor: scheme.onErrorContainer, + icon: Icon(Icons.wifi_off_rounded, color: scheme.onErrorContainer, size: 18), + label: label, + showProgress: false, + onTap: () => showOfflineReadinessSheet(context), + ); + } else if (isWarming) { + banner = _BannerStrip( + key: const ValueKey('warming'), + backgroundColor: scheme.secondaryContainer, + foregroundColor: scheme.onSecondaryContainer, + icon: _SpinningIcon( + icon: Icons.cloud_sync_rounded, + color: scheme.onSecondaryContainer, + size: 18, + ), + label: 'Updating offline cache…', + showProgress: true, + onTap: () => showOfflineReadinessSheet(context), + ); + } else if (pendingCount > 0) { + final label = 'Syncing $pendingCount item${pendingCount == 1 ? '' : 's'}…'; + banner = _BannerStrip( + key: const ValueKey('syncing'), + backgroundColor: scheme.tertiaryContainer, + foregroundColor: scheme.onTertiaryContainer, + icon: _SpinningIcon( + icon: Icons.sync_rounded, + color: scheme.onTertiaryContainer, + size: 18, + ), + label: label, + showProgress: true, + onTap: () => showOfflineReadinessSheet(context), + ); + } - return Column( - children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 250), - transitionBuilder: (child, animation) { - return SlideTransition( - position: Tween( - begin: const Offset(0, -1), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - )), - child: FadeTransition(opacity: animation, child: child), - ); - }, - child: banner ?? const SizedBox.shrink(key: ValueKey('none')), - ), - Expanded(child: child), - ], + return Column( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + transitionBuilder: (child, animation) { + return SlideTransition( + position: Tween( + begin: const Offset(0, -1), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + )), + child: FadeTransition(opacity: animation, child: child), + ); + }, + child: banner ?? const SizedBox.shrink(key: ValueKey('none')), + ), + Expanded(child: child), + ], + ); + }, ); } } @@ -84,6 +107,7 @@ class _BannerStrip extends StatelessWidget { final Widget icon; final String label; final bool showProgress; + final VoidCallback? onTap; const _BannerStrip({ super.key, @@ -92,50 +116,60 @@ class _BannerStrip extends StatelessWidget { required this.icon, required this.label, required this.showProgress, + this.onTap, }); @override Widget build(BuildContext context) { + const radius = BorderRadius.vertical(bottom: Radius.circular(12)); return Material( color: backgroundColor, - borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)), - child: SafeArea( - bottom: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: EdgeInsets.fromLTRB(16, 8, 16, showProgress ? 6 : 8), - child: Row( - children: [ - icon, - const SizedBox(width: 10), - Expanded( - child: Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: foregroundColor, - fontWeight: FontWeight.w500, - ), + borderRadius: radius, + child: InkWell( + borderRadius: radius, + onTap: onTap, + child: SafeArea( + bottom: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(16, 8, onTap != null ? 12 : 16, showProgress ? 6 : 8), + child: Row( + children: [ + icon, + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w500, + ), + ), + ), + if (onTap != null) + Icon( + Icons.chevron_right_rounded, + size: 16, + color: foregroundColor.withValues(alpha: 0.6), + ), + ], + ), + ), + if (showProgress) + ClipRRect( + borderRadius: radius, + child: LinearProgressIndicator( + minHeight: 2, + backgroundColor: backgroundColor, + valueColor: AlwaysStoppedAnimation( + foregroundColor.withValues(alpha: 0.6), ), ), - ], - ), - ), - if (showProgress) - ClipRRect( - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(12), ), - child: LinearProgressIndicator( - minHeight: 2, - backgroundColor: backgroundColor, - valueColor: AlwaysStoppedAnimation( - foregroundColor.withValues(alpha: 0.6), - ), - ), - ), - ], + ], + ), ), ), ); diff --git a/lib/widgets/offline_readiness_sheet.dart b/lib/widgets/offline_readiness_sheet.dart new file mode 100644 index 00000000..604c9030 --- /dev/null +++ b/lib/widgets/offline_readiness_sheet.dart @@ -0,0 +1,490 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../brick/cache_warmer.dart'; +import '../providers/connectivity_provider.dart'; +import '../providers/offline_readiness_provider.dart'; +import '../providers/supabase_provider.dart'; +import '../providers/sync_queue_provider.dart'; + +void showOfflineReadinessSheet(BuildContext context) { + final container = ProviderScope.containerOf(context); + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => UncontrolledProviderScope( + container: container, + child: const _OfflineReadinessSheet(), + ), + ); +} + +class _OfflineReadinessSheet extends ConsumerStatefulWidget { + const _OfflineReadinessSheet(); + + @override + ConsumerState<_OfflineReadinessSheet> createState() => _OfflineReadinessSheetState(); +} + +class _OfflineReadinessSheetState extends ConsumerState<_OfflineReadinessSheet> { + bool _refreshing = false; + + Future _refresh() async { + if (_refreshing) return; + setState(() => _refreshing = true); + try { + final client = ref.read(supabaseClientProvider); + await CacheWarmer.warmAll(client); + ref.invalidate(offlineReadinessProvider); + } finally { + if (mounted) setState(() => _refreshing = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final readiness = ref.watch(offlineReadinessProvider); + final isOnline = ref.watch(isOnlineProvider); + + return DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.92, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + // Handle bar + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: scheme.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Header + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 16, 12), + child: Row( + children: [ + Icon(Icons.offline_bolt_rounded, color: scheme.primary, size: 22), + const SizedBox(width: 10), + Text( + 'Offline Readiness', + style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600), + ), + const Spacer(), + if (isOnline) + _refreshing + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: scheme.primary), + ) + : IconButton( + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Refresh cache', + onPressed: _refresh, + ), + ], + ), + ), + const Divider(height: 1), + // Body + Expanded( + child: readiness.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Text('Unable to load status', style: TextStyle(color: scheme.error)), + ), + data: (state) => _ReadinessBody( + state: state, + isOnline: isOnline, + scrollController: scrollController, + onRefresh: _refresh, + ), + ), + ), + ], + ); + }, + ); + } +} + +class _ReadinessBody extends ConsumerWidget { + final OfflineReadinessState state; + final bool isOnline; + final ScrollController scrollController; + final VoidCallback onRefresh; + + const _ReadinessBody({ + required this.state, + required this.isOnline, + required this.scrollController, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final breakdown = ref.watch(pendingSyncBreakdownProvider); + + return ListView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 32), + children: [ + // Overall status chip + _StatusChip(isReady: state.isReady, isOnline: isOnline), + const SizedBox(height: 20), + + // Cached data section + Text( + 'Cached Data', + style: theme.textTheme.labelLarge?.copyWith( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _CacheGrid(counts: state.cacheCounts), + + // Pending sync section — shown whenever any module has pending items + if (breakdown.isNotEmpty) ...[ + const SizedBox(height: 20), + Text( + 'Pending Sync', + style: theme.textTheme.labelLarge?.copyWith( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _PendingList(breakdown: breakdown, isOnline: isOnline), + ], + + // Prompt to go online if not ready and not warmed + if (!state.hasWarmedOnce && !isOnline) ...[ + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: scheme.errorContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.info_outline_rounded, color: scheme.error, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Connect to the internet at least once to download offline data.', + style: theme.textTheme.bodySmall?.copyWith(color: scheme.onErrorContainer), + ), + ), + ], + ), + ), + ], + + if (!isOnline && state.hasWarmedOnce) ...[ + const SizedBox(height: 20), + FilledButton.tonalIcon( + onPressed: null, + icon: const Icon(Icons.cloud_off_rounded, size: 18), + label: const Text('Connect to refresh cache'), + ), + ] else if (isOnline) ...[ + const SizedBox(height: 20), + FilledButton.tonalIcon( + onPressed: onRefresh, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Refresh Cache Now'), + ), + ], + ], + ); + } +} + +class _StatusChip extends StatelessWidget { + final bool isReady; + final bool isOnline; + + const _StatusChip({required this.isReady, required this.isOnline}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final ready = isReady; + final bg = ready ? scheme.primaryContainer : scheme.errorContainer; + final fg = ready ? scheme.onPrimaryContainer : scheme.onErrorContainer; + final icon = ready ? Icons.check_circle_rounded : Icons.warning_amber_rounded; + final label = ready ? 'Ready for offline use' : 'Cache incomplete — go online to set up'; + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Container( + key: ValueKey(ready), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(12)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: fg, size: 18), + const SizedBox(width: 8), + Flexible( + child: Text(label, style: TextStyle(color: fg, fontWeight: FontWeight.w500)), + ), + ], + ), + ), + ); + } +} + +class _CacheGrid extends StatelessWidget { + final Map counts; + + const _CacheGrid({required this.counts}); + + static const _groups = [ + ('Tasks', 'tasks', Icons.task_alt_rounded), + ('Tickets', 'tickets', Icons.confirmation_number_rounded), + ('Announcements', 'announcements', Icons.campaign_rounded), + ('Attendance', 'attendance_logs', Icons.fingerprint_rounded), + ('Schedules', 'duty_schedules', Icons.calendar_month_rounded), + ('Pass Slips', 'pass_slips', Icons.badge_rounded), + ('Leaves', 'leave_of_absence', Icons.beach_access_rounded), + ('Profiles', 'profiles', Icons.people_rounded), + ('Teams', 'teams', Icons.groups_rounded), + ('IT Requests', 'it_service_requests', Icons.computer_rounded), + ('Notifications', 'notifications', Icons.notifications_rounded), + ('Offices', 'offices', Icons.business_rounded), + ]; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1.1, + ), + itemCount: _groups.length, + itemBuilder: (context, i) { + final (label, key, icon) = _groups[i]; + final count = counts[key] ?? -1; + final cached = count >= 0; + return AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + child: _CacheTile( + key: ValueKey('$key-$count'), + label: label, + icon: icon, + count: count, + cached: cached, + scheme: scheme, + ), + ); + }, + ); + } +} + +class _CacheTile extends StatelessWidget { + final String label; + final IconData icon; + final int count; + final bool cached; + final ColorScheme scheme; + + const _CacheTile({ + super.key, + required this.label, + required this.icon, + required this.count, + required this.cached, + required this.scheme, + }); + + @override + Widget build(BuildContext context) { + final bg = cached ? scheme.primaryContainer.withValues(alpha: 0.5) : scheme.surfaceContainerHighest; + final fg = cached ? scheme.onPrimaryContainer : scheme.onSurfaceVariant; + return Container( + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.all(10), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: fg, size: 22), + const SizedBox(height: 4), + Text( + label, + style: TextStyle(color: fg, fontSize: 10, fontWeight: FontWeight.w500), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + cached ? '$count items' : 'Not cached', + style: TextStyle(color: fg.withValues(alpha: 0.7), fontSize: 9), + ), + ], + ), + ); + } +} + +class _PendingList extends StatelessWidget { + final Map breakdown; + final bool isOnline; + + const _PendingList({required this.breakdown, required this.isOnline}); + + static const _icons = { + 'tasks': Icons.task_alt_rounded, + 'tickets': Icons.confirmation_number_rounded, + 'messages': Icons.chat_bubble_rounded, + 'assignments': Icons.assignment_ind_rounded, + 'activity_logs': Icons.history_rounded, + 'check_ins': Icons.login_rounded, + 'attendance': Icons.fingerprint_rounded, + 'leaves': Icons.beach_access_rounded, + 'pass_slips': Icons.badge_rounded, + 'announcements': Icons.campaign_rounded, + 'it_requests': Icons.computer_rounded, + 'notification_reads': Icons.notifications_rounded, + 'profile_updates': Icons.person_rounded, + 'office_changes': Icons.business_rounded, + }; + + static String _label(String key, int count) { + final n = count.toString(); + final s = count == 1 ? '' : 's'; + switch (key) { + case 'tasks': return '$n task$s'; + case 'tickets': return '$n ticket$s'; + case 'messages': return '$n message$s'; + case 'assignments': return '$n assignment$s'; + case 'activity_logs': return '$n activity log$s'; + case 'check_ins': return '$n check-in$s'; + case 'attendance': return '$n attendance update$s'; + case 'leaves': return '$n leave$s'; + case 'pass_slips': return '$n pass slip$s'; + case 'announcements': return '$n announcement$s'; + case 'it_requests': return '$n IT request$s'; + case 'notification_reads': return '$n notification read$s'; + case 'profile_updates': return '$n profile update$s'; + case 'office_changes': return '$n office change$s'; + default: return '$n $key'; + } + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final syncing = isOnline && breakdown.isNotEmpty; + + return Column( + children: [ + for (int i = 0; i < breakdown.length; i++) ...[ + if (i > 0) const SizedBox(height: 6), + _PendingTile( + icon: _icons[breakdown.keys.elementAt(i)] ?? Icons.sync_rounded, + label: _label(breakdown.keys.elementAt(i), breakdown.values.elementAt(i)), + syncing: syncing, + scheme: scheme, + ), + ], + ], + ); + } +} + +class _PendingTile extends StatelessWidget { + final IconData icon; + final String label; + final bool syncing; + final ColorScheme scheme; + + const _PendingTile({ + required this.icon, + required this.label, + required this.syncing, + required this.scheme, + }); + + @override + Widget build(BuildContext context) { + final bg = syncing ? scheme.tertiaryContainer : scheme.surfaceContainerHighest; + final fg = syncing ? scheme.onTertiaryContainer : scheme.onSurfaceVariant; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(10)), + child: Row( + children: [ + syncing ? _SpinningIcon(icon: icon, color: fg) : Icon(icon, color: fg, size: 18), + const SizedBox(width: 10), + Expanded( + child: Text(label, style: TextStyle(color: fg, fontWeight: FontWeight.w500)), + ), + if (syncing) + Text('Syncing…', style: TextStyle(color: fg.withValues(alpha: 0.7), fontSize: 12)), + ], + ), + ); + } +} + +class _SpinningIcon extends StatefulWidget { + final IconData icon; + final Color color; + + const _SpinningIcon({required this.icon, required this.color}); + + @override + State<_SpinningIcon> createState() => _SpinningIconState(); +} + +class _SpinningIconState extends State<_SpinningIcon> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 2))..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RotationTransition( + turns: _ctrl, + child: Icon(widget.icon, color: widget.color, size: 18), + ); + } +}