import 'dart:async'; import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../providers/api_client_provider.dart'; import '../providers/auth_provider.dart'; /// Sticky banner shown when the backend is unreachable. Surfaces a retry /// action plus a "last sync X ago" hint when there is cached content to /// fall back on (Tier 2 offline mode). class OfflineBanner extends ConsumerStatefulWidget { const OfflineBanner({super.key}); @override ConsumerState createState() => _OfflineBannerState(); } class _OfflineBannerState extends ConsumerState { bool _retrying = false; DateTime? _lastSync; Timer? _refreshTimer; @override void initState() { super.initState(); _loadLastSync(); // Refresh the relative-time string every 30s so "5 min ago" rolls // forward without the user having to interact with the banner. _refreshTimer = Timer.periodic( const Duration(seconds: 30), (_) => _loadLastSync(), ); } @override void dispose() { _refreshTimer?.cancel(); super.dispose(); } Future _loadLastSync() async { if (!mounted) return; try { // Most-recent sync across all cached domains so the hint reads as // "your data was current as of X" regardless of which screen the // user is looking at. final ts = await ref.read(fabledDatabaseProvider).getLatestSync(); if (!mounted) return; setState(() => _lastSync = ts); } catch (_) { // Non-critical — banner just won't show the sync hint. } } Future _retry() async { if (_retrying) return; setState(() => _retrying = true); try { await ref.read(authProvider.notifier).verify(); } finally { if (mounted) setState(() => _retrying = false); } } String? _relativeAgo(DateTime ts) { final diff = DateTime.now().difference(ts); if (diff.isNegative) return null; if (diff.inMinutes < 1) return 'just now'; if (diff.inMinutes < 60) return '${diff.inMinutes} min ago'; if (diff.inHours < 24) { final h = diff.inHours; return '$h ${h == 1 ? 'hour' : 'hours'} ago'; } final d = diff.inDays; return '$d ${d == 1 ? 'day' : 'days'} ago'; } @override Widget build(BuildContext context) { final status = ref.watch(authProvider); if (status != AuthStatus.offline) return const SizedBox.shrink(); final scheme = Theme.of(context).colorScheme; final ago = _lastSync == null ? null : _relativeAgo(_lastSync!); // Pending queue depth — shown on the Retry button so the user knows // there's offline work waiting to land when they come back online. final pending = ref.watch(writeQueueDepthProvider).asData?.value ?? 0; final baseMessage = ago == null ? 'Offline — showing cached data.' : 'Offline — last sync $ago.'; final message = pending > 0 ? '$baseMessage $pending pending change${pending == 1 ? '' : 's'}.' : baseMessage; final retryLabel = pending > 0 ? 'Retry ($pending)' : 'Retry'; return Material( color: scheme.errorContainer, child: SafeArea( bottom: false, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ Icon(LucideIcons.cloudOff, size: 18, color: scheme.onErrorContainer), const SizedBox(width: 8), Expanded( child: Text( message, style: TextStyle(color: scheme.onErrorContainer), ), ), TextButton( onPressed: _retrying ? null : _retry, style: TextButton.styleFrom( foregroundColor: scheme.onErrorContainer, padding: const EdgeInsets.symmetric(horizontal: 12), ), child: _retrying ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(retryLabel), ), ], ), ), ), ); } }