Offline Support
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/connectivity_provider.dart';
|
||||
import '../providers/sync_queue_provider.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;
|
||||
|
||||
Widget? banner;
|
||||
|
||||
if (!isOnline) {
|
||||
final label = pendingCount > 0
|
||||
? 'Offline — $pendingCount item${pendingCount == 1 ? '' : 's'} queued'
|
||||
: '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,
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const _BannerStrip({
|
||||
super.key,
|
||||
required this.backgroundColor,
|
||||
required this.foregroundColor,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.showProgress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showProgress)
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(12),
|
||||
),
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user