Files
tasq/lib/widgets/offline_readiness_sheet.dart
T

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),
);
}
}