533 lines
17 KiB
Dart
533 lines
17 KiB
Dart
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 StatefulWidget {
|
|
final Map<String, int> counts;
|
|
|
|
const _CacheGrid({required this.counts});
|
|
|
|
@override
|
|
State<_CacheGrid> createState() => _CacheGridState();
|
|
}
|
|
|
|
class _CacheGridState extends State<_CacheGrid> with SingleTickerProviderStateMixin {
|
|
late final AnimationController _controller;
|
|
|
|
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
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
duration: const Duration(milliseconds: 800),
|
|
vsync: this,
|
|
)..forward();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@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: 0.9,
|
|
),
|
|
itemCount: _groups.length,
|
|
itemBuilder: (context, i) {
|
|
final (label, key, icon) = _groups[i];
|
|
final count = widget.counts[key] ?? -1;
|
|
final cached = count >= 0;
|
|
|
|
final start = i * 0.055;
|
|
final end = (start + 0.45).clamp(0.0, 1.0);
|
|
final curve = CurvedAnimation(
|
|
parent: _controller,
|
|
curve: Interval(start, end, curve: Curves.easeOutCubic),
|
|
);
|
|
final fadeAnim = Tween<double>(begin: 0.0, end: 1.0).animate(curve);
|
|
final slideAnim = Tween<Offset>(
|
|
begin: const Offset(0, 0.25),
|
|
end: Offset.zero,
|
|
).animate(curve);
|
|
|
|
return FadeTransition(
|
|
opacity: fadeAnim,
|
|
child: SlideTransition(
|
|
position: slideAnim,
|
|
child: 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(14)),
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(icon, color: fg, size: 24),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
label,
|
|
style: TextStyle(color: fg, fontSize: 11, fontWeight: FontWeight.w500),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
cached ? '$count items' : 'Not cached',
|
|
style: TextStyle(color: fg.withValues(alpha: 0.7), fontSize: 10),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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),
|
|
);
|
|
}
|
|
}
|