import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart' show ValueNotifier, 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'; /// 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; /// True when at least one warm has completed since app start. 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; /// 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; warmingNotifier.value = true; final future = _runWarmAll(client); _inflight = future; // 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 { 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)); } 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. _safe('team_members', () async { 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(); 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)); } 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)); } 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)); } 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)); } 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)); } } 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)); } 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)); } 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)); } await _writeCount('leave_of_absence', rows.length); } 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)); } 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)); } 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)); } await _writeCount('notifications', rows.length); } // ------------------------------------------------------------------ // 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; }()); } } /// 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, }; } }