Offline rediness

This commit is contained in:
2026-05-03 14:50:14 +08:00
parent 783c5eb0be
commit 858520bd8d
13 changed files with 893 additions and 205 deletions
+91 -113
View File
@@ -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,
};
}
}
+38 -2
View File
@@ -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,
);
});
+70
View File
@@ -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;
});
+10 -3
View File
@@ -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);
-1
View File
@@ -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);
+1
View File
@@ -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);
+4 -1
View File
@@ -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: [
+4 -1
View File
@@ -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,
+14
View File
@@ -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
View File
@@ -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),
),
),
),
],
],
),
),
),
);
+490
View File
@@ -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),
);
}
}