Offline rediness
This commit is contained in:
+91
-113
@@ -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<bool> warmingNotifier = ValueNotifier<bool>(false);
|
||||
|
||||
/// Single-flight guard to prevent overlapping warms.
|
||||
static Future<void>? _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<void> _runWarmAll(SupabaseClient client) async {
|
||||
@@ -121,24 +139,28 @@ class CacheWarmer {
|
||||
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.
|
||||
@@ -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<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));
|
||||
}
|
||||
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));
|
||||
}
|
||||
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));
|
||||
}
|
||||
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));
|
||||
}
|
||||
// Also pull comments for the windowed announcements so detail screens have
|
||||
// them offline.
|
||||
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),
|
||||
);
|
||||
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));
|
||||
}
|
||||
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));
|
||||
}
|
||||
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));
|
||||
}
|
||||
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 {
|
||||
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));
|
||||
}
|
||||
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));
|
||||
}
|
||||
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));
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -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<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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,19 +175,55 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((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<String, Map<String, dynamic>>.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<String, Map<String, dynamic>>.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',
|
||||
|
||||
@@ -419,6 +419,12 @@ class NotificationsController {
|
||||
final current = List<Map<String, dynamic>>.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,
|
||||
|
||||
@@ -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<String, int> 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<OfflineReadinessState>((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,
|
||||
);
|
||||
});
|
||||
@@ -105,3 +105,73 @@ final pendingSyncCountProvider = Provider<int>((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<Map<String, int>>((ref) {
|
||||
final result = <String, int>{};
|
||||
|
||||
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<int>(0, (s, l) => s + l.length) +
|
||||
ref
|
||||
.watch(offlinePendingChatMessagesProvider)
|
||||
.values
|
||||
.fold<int>(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;
|
||||
});
|
||||
|
||||
@@ -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<List<Team>>((ref) {
|
||||
@@ -19,6 +21,7 @@ final teamsProvider = StreamProvider<List<Team>>((ref) {
|
||||
fromMap: Team.fromMap,
|
||||
channelName: 'teams',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async => cachedListFromBrick<Team>(),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
@@ -40,6 +43,10 @@ final teamMembersProvider = StreamProvider<List<TeamMember>>((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);
|
||||
|
||||
@@ -30,7 +30,6 @@ final officesProvider = StreamProvider<List<Office>>((ref) {
|
||||
final pendingOffices = ref.watch(offlinePendingOfficesProvider);
|
||||
|
||||
List<Office> applyPending(List<Office> 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);
|
||||
|
||||
@@ -25,6 +25,7 @@ final livePositionsProvider = StreamProvider<List<LivePosition>>((ref) {
|
||||
fromMap: LivePosition.fromMap,
|
||||
channelName: 'live_positions',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async => const [],
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
|
||||
@@ -961,7 +961,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
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: [
|
||||
|
||||
@@ -471,7 +471,10 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||
if (offices.isEmpty) return const Text('No offices assigned.');
|
||||
final officesSorted = List<Office>.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<Office>(
|
||||
key: ValueKey(selectedOffice?.id),
|
||||
initialValue: selectedOffice,
|
||||
|
||||
@@ -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<void> _showOverflowSheet(
|
||||
List<NavItem> items,
|
||||
VoidCallback onLogout,
|
||||
) async {
|
||||
final parentContext = context;
|
||||
await m3ShowBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
@@ -616,6 +618,18 @@ Future<void> _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);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
+118
-84
@@ -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<bool>(
|
||||
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<Offset>(
|
||||
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<Offset>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<void>(
|
||||
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<void> _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<String, int> 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<String, int> breakdown;
|
||||
final bool isOnline;
|
||||
|
||||
const _PendingList({required this.breakdown, required this.isOnline});
|
||||
|
||||
static const _icons = <String, IconData>{
|
||||
'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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user