Offline rediness

This commit is contained in:
2026-05-03 14:50:14 +08:00
parent 783c5eb0be
commit 858520bd8d
13 changed files with 893 additions and 205 deletions
+118 -84
View File
@@ -2,8 +2,10 @@ 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.
///
@@ -23,57 +25,78 @@ class OfflineBanner extends ConsumerWidget {
final pendingCount = ref.watch(pendingSyncCountProvider);
final scheme = Theme.of(context).colorScheme;
Widget? banner;
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,
);
} 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,
);
}
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),
],
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),
],
);
},
);
}
}
@@ -84,6 +107,7 @@ class _BannerStrip extends StatelessWidget {
final Widget icon;
final String label;
final bool showProgress;
final VoidCallback? onTap;
const _BannerStrip({
super.key,
@@ -92,50 +116,60 @@ class _BannerStrip extends StatelessWidget {
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: const BorderRadius.vertical(bottom: Radius.circular(12)),
child: SafeArea(
bottom: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.fromLTRB(16, 8, 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,
),
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),
),
),
],
),
),
if (showProgress)
ClipRRect(
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(12),
),
child: LinearProgressIndicator(
minHeight: 2,
backgroundColor: backgroundColor,
valueColor: AlwaysStoppedAnimation(
foregroundColor.withValues(alpha: 0.6),
),
),
),
],
],
),
),
),
);