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 { final ts = await ref.read(notesRepositoryProvider).lastSyncedAt(); 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!); final message = ago == null ? 'Offline — showing cached data.' : 'Offline — last sync $ago.'; 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), ) : const Text('Retry'), ), ], ), ), ), ); } }