Files
tasq/lib/widgets/sync_pending_badge.dart
T
2026-04-27 06:20:39 +08:00

145 lines
3.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/connectivity_provider.dart';
/// Two-state "this item is not yet synced" indicator.
///
/// Renders nothing when [isPending] is false. Otherwise shows:
/// - **Amber** chip with a static sync icon when the device is offline
/// ("Pending sync" — queued, will sync when network returns).
/// - **Blue** chip with a rotating sync icon when the device is online
/// ("Syncing…" — replay-in-flight or Brick queue draining).
///
/// Use [compact] for dense list rows where only the icon is needed.
class SyncPendingBadge extends ConsumerWidget {
/// Whether the entity associated with this badge is still queued for sync.
/// Caller is responsible for deriving this from the appropriate state
/// provider (e.g., `offlinePendingTasksProvider.any((t) => t.id == taskId)`).
final bool isPending;
/// Compact (icon-only) variant for dense list rows.
final bool compact;
/// Optional override label — defaults to "Pending sync" (offline) or
/// "Syncing…" (online).
final String? label;
const SyncPendingBadge({
super.key,
required this.isPending,
this.compact = false,
this.label,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (!isPending) return const SizedBox.shrink();
final isOnline = ref.watch(isOnlineProvider);
final scheme = Theme.of(context).colorScheme;
final Color bg;
final Color fg;
final IconData icon;
final String text;
final bool spin;
if (isOnline) {
// Replay-in-flight: blue, animated.
bg = scheme.primaryContainer;
fg = scheme.onPrimaryContainer;
icon = Icons.sync;
text = label ?? 'Syncing…';
spin = true;
} else {
// Queued offline: amber, static.
bg = Colors.amber.shade100;
fg = Colors.amber.shade900;
icon = Icons.sync;
text = label ?? 'Pending sync';
spin = false;
}
final iconWidget = spin
? _RotatingIcon(icon: icon, color: fg, size: compact ? 12 : 14)
: Icon(icon, color: fg, size: compact ? 12 : 14);
if (compact) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(8),
),
child: iconWidget,
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
iconWidget,
const SizedBox(width: 4),
Text(
text,
style: TextStyle(
color: fg,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
),
),
],
),
);
}
}
class _RotatingIcon extends StatefulWidget {
final IconData icon;
final Color color;
final double size;
const _RotatingIcon({
required this.icon,
required this.color,
required this.size,
});
@override
State<_RotatingIcon> createState() => _RotatingIconState();
}
class _RotatingIconState extends State<_RotatingIcon>
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),
);
}
}