339 lines
14 KiB
Dart
339 lines
14 KiB
Dart
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<bool> warmingNotifier = ValueNotifier<bool>(false);
|
|
|
|
/// Single-flight guard to prevent overlapping warms.
|
|
static Future<void>? _inflight;
|
|
|
|
/// Warm every cacheable table. Safe to call repeatedly.
|
|
static Future<void> 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<void> _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<void>([
|
|
_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<void> _warmReference(SupabaseClient client) async {
|
|
await Future.wait<void>([
|
|
_safe('offices', () async {
|
|
final rows = await client.from('offices').select();
|
|
for (final row in rows) {
|
|
await _localUpsert<Office>(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>(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>(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>(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<List<Map<String, dynamic>>> 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<Map<String, dynamic>>();
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
/// Read cached user_offices rows from SharedPreferences.
|
|
static Future<List<Map<String, dynamic>>> 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<Map<String, dynamic>>();
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Per-module warms — windowed by created_at >= since
|
|
// ------------------------------------------------------------------
|
|
|
|
static Future<void> _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>(Task.fromMap(row)); }
|
|
await _writeCount('tasks', rows.length);
|
|
}
|
|
|
|
static Future<void> _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>(Ticket.fromMap(row)); }
|
|
await _writeCount('tickets', rows.length);
|
|
}
|
|
|
|
static Future<void> _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>(ItServiceRequest.fromMap(row)); }
|
|
await _writeCount('it_service_requests', rows.length);
|
|
}
|
|
|
|
static Future<void> _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>(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>(AnnouncementComment.fromMap(row));
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[CacheWarmer] announcement_comments failed: $e');
|
|
}
|
|
}
|
|
|
|
static Future<void> _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>(AttendanceLog.fromMap(row)); }
|
|
await _writeCount('attendance_logs', rows.length);
|
|
}
|
|
|
|
static Future<void> _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>(PassSlip.fromMap(row)); }
|
|
await _writeCount('pass_slips', rows.length);
|
|
}
|
|
|
|
static Future<void> _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>(LeaveOfAbsence.fromMap(row)); }
|
|
await _writeCount('leave_of_absence', rows.length);
|
|
}
|
|
|
|
static Future<void> _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>(DutySchedule.fromMap(row)); }
|
|
await _writeCount('duty_schedules', rows.length);
|
|
}
|
|
|
|
static Future<void> _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>(SwapRequest.fromMap(row)); }
|
|
await _writeCount('swap_requests', rows.length);
|
|
}
|
|
|
|
static Future<void> _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>(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<void> _localUpsert<T extends OfflineFirstWithSupabaseModel>(
|
|
T model,
|
|
) async {
|
|
await AppRepository.instance.sqliteProvider
|
|
.upsert<T>(model, repository: AppRepository.instance);
|
|
}
|
|
|
|
static Future<void> _safe(String label, Future<void> 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<void> _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<Map<String, int>> 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,
|
|
};
|
|
}
|
|
}
|