221 lines
6.9 KiB
Dart
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),
|
|
);
|
|
}
|
|
}
|