Files
tasq/lib/widgets/offline_banner.dart
T
2026-05-03 14:50:14 +08:00

221 lines
6.9 KiB
Dart

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.
///
/// Three states:
/// 1. Online + 0 pending → no banner (slides away)
/// 2. Online + N pending → tertiary-toned "Syncing N item(s)…" with spinner + progress bar
/// 3. Offline → error-toned "Offline — N item(s) queued" (count omitted when 0)
class OfflineBanner extends ConsumerWidget {
final Widget child;
const OfflineBanner({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(connectivityMonitorProvider);
final isOnline = ref.watch(isOnlineProvider);
final pendingCount = ref.watch(pendingSyncCountProvider);
final scheme = Theme.of(context).colorScheme;
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,
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),
],
);
},
);
}
}
class _BannerStrip extends StatelessWidget {
final Color backgroundColor;
final Color foregroundColor;
final Widget icon;
final String label;
final bool showProgress;
final VoidCallback? onTap;
const _BannerStrip({
super.key,
required this.backgroundColor,
required this.foregroundColor,
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: 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),
),
),
),
],
),
),
),
);
}
}
class _SpinningIcon extends StatefulWidget {
final IconData icon;
final Color color;
final double size;
const _SpinningIcon({
required this.icon,
required this.color,
required this.size,
});
@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: widget.size),
);
}
}