import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/announcement.model.dart'; import '../models/announcement_comment.model.dart'; import '../models/attendance_log.model.dart'; import '../models/duty_schedule.model.dart'; import '../models/it_service_request.model.dart'; import '../models/leave_of_absence.model.dart'; import '../models/notification_item.model.dart'; import '../models/office.model.dart'; import '../models/pass_slip.model.dart'; import '../models/profile.model.dart'; import '../models/service.model.dart'; import '../models/swap_request.model.dart'; import '../models/task.model.dart'; import '../models/team.model.dart'; import '../models/ticket.model.dart'; import 'repository.dart'; /// Proactively seeds the local Brick SQLite cache with the data the app needs /// to render correctly when launched offline. /// /// Triggered: /// - After successful login. /// - After the connectivity monitor flips offline → online. /// - On a 1-hour timer in the foreground (defensive refresh). /// /// Never throws — a single table failure must not abort the rest. Each table /// is wrapped in try/catch and only logs the error. /// /// Web is a no-op because Brick's SQLite is unavailable on web. class CacheWarmer { CacheWarmer._(); /// Window of mutable data to keep locally. 30 days is enough for the /// "month of tasks/tickets" the user requested without bloating SQLite. static const Duration window = Duration(days: 30); /// SharedPreferences keys for association tables (no PK → not Brick models). static const _kTeamMembersKey = 'cache_team_members_json'; static const _kUserOfficesKey = 'cache_user_offices_json'; /// Foreground timer handle so [start]/[stop] is idempotent. static Timer? _refreshTimer; /// True when at least one warm has completed since app start. static bool _hasWarmedOnce = false; static bool get hasWarmedOnce => _hasWarmedOnce; /// Single-flight guard to prevent overlapping warms. static Future? _inflight; /// Warm every cacheable table. Safe to call repeatedly. static Future warmAll(SupabaseClient client) { if (kIsWeb) return Future.value(); if (!AppRepository.isConfigured) return Future.value(); final existing = _inflight; if (existing != null) return existing; final future = _runWarmAll(client); _inflight = future; return future.whenComplete(() => _inflight = null); } static Future _runWarmAll(SupabaseClient client) async { final stopwatch = Stopwatch()..start(); final since = DateTime.now().toUtc().subtract(window); final sinceIso = since.toIso8601String(); debugPrint('[CacheWarmer] starting (since=$sinceIso)'); // Run reference + per-module warms in parallel — a single failure in one // table must not block the others. await Future.wait([ _safe('reference', () => _warmReference(client)), _safe('tasks', () => _warmTasks(client, sinceIso)), _safe('tickets', () => _warmTickets(client, sinceIso)), _safe('itsr', () => _warmItServiceRequests(client, sinceIso)), _safe('announcements', () => _warmAnnouncements(client, sinceIso)), _safe('attendance', () => _warmAttendance(client, sinceIso)), _safe('pass_slips', () => _warmPassSlips(client, sinceIso)), _safe('leaves', () => _warmLeaves(client, sinceIso)), _safe('duty_schedules', () => _warmDutySchedules(client, sinceIso)), _safe('swap_requests', () => _warmSwapRequests(client, sinceIso)), _safe('notifications', () => _warmNotifications(client, sinceIso)), ]); _hasWarmedOnce = true; stopwatch.stop(); debugPrint('[CacheWarmer] done in ${stopwatch.elapsedMilliseconds}ms'); } /// Schedule a periodic re-warm. Idempotent. static void startPeriodicRefresh(SupabaseClient client) { if (kIsWeb) return; _refreshTimer?.cancel(); _refreshTimer = Timer.periodic(const Duration(hours: 1), (_) { warmAll(client); }); } /// Cancel periodic re-warm (e.g. on sign-out). static void stopPeriodicRefresh() { _refreshTimer?.cancel(); _refreshTimer = null; } // ------------------------------------------------------------------ // Reference data — small, rarely-mutating. Pulled in full. // ------------------------------------------------------------------ static Future _warmReference(SupabaseClient client) async { await Future.wait([ _safe('offices', () async { final rows = await client.from('offices').select(); for (final row in rows) { await _localUpsert(Office.fromMap(row)); } }), _safe('services', () async { final rows = await client.from('services').select(); for (final row in rows) { await _localUpsert(Service.fromMap(row)); } }), _safe('profiles', () async { final rows = await client.from('profiles').select(); for (final row in rows) { await _localUpsert(Profile.fromMap(row)); } }), _safe('teams', () async { final rows = await client.from('teams').select(); for (final row in rows) { await _localUpsert(Team.fromMap(row)); } }), // Association tables have composite PKs, so we cache them as JSON in // SharedPreferences instead of Brick. The list is small and read-only. _safe('team_members', () async { final rows = await client.from('team_members').select(); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kTeamMembersKey, jsonEncode(rows)); }), _safe('user_offices', () async { final rows = await client.from('user_offices').select(); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kUserOfficesKey, jsonEncode(rows)); }), ]); } /// Read cached team_members rows from SharedPreferences. /// Returns the same shape as the Supabase select (`[{team_id, user_id}, ...]`). static Future>> readCachedTeamMembers() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_kTeamMembersKey); if (raw == null) return const []; try { final list = jsonDecode(raw); if (list is! List) return const []; return list.cast>(); } catch (_) { return const []; } } /// Read cached user_offices rows from SharedPreferences. static Future>> readCachedUserOffices() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_kUserOfficesKey); if (raw == null) return const []; try { final list = jsonDecode(raw); if (list is! List) return const []; return list.cast>(); } catch (_) { return const []; } } // ------------------------------------------------------------------ // Per-module warms — windowed by created_at >= since // ------------------------------------------------------------------ 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)); } } 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 _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 _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. try { final commentRows = await client .from('announcement_comments') .select() .gte('created_at', sinceIso); for (final row in commentRows) { 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 _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 _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 _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)); } } 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 _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)); } } // ------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------ /// Writes [model] to local SQLite only, bypassing Brick's bidirectional /// sync. AppRepository.upsert would re-POST rows to Supabase, causing RLS /// violations for other users' records and deadlocks from concurrent writes. static Future _localUpsert( T model, ) async { await AppRepository.instance.sqliteProvider .upsert(model, repository: AppRepository.instance); } static Future _safe(String label, Future Function() task) async { try { await task(); } catch (e, st) { debugPrint('[CacheWarmer] $label failed: $e'); assert(() { debugPrint('$st'); return true; }()); } } }