Offline Support

This commit is contained in:
2026-04-27 06:20:39 +08:00
parent 7d8851a94a
commit 48cd3bcd60
121 changed files with 14798 additions and 2214 deletions
+185
View File
@@ -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),
);
}
}
+10 -2
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/realtime_controller.dart';
import '../providers/sync_queue_provider.dart';
/// Subtle, non-blocking connection status indicator.
/// Shows in the bottom-right corner when streams are recovering/stale.
@@ -18,12 +19,19 @@ class ReconnectIndicator extends ConsumerWidget {
return const SizedBox.shrink();
}
final pendingCount = ref.watch(pendingSyncCountProvider);
// Build a human-readable label for recovering channels.
final channels = ctrl.recoveringChannels;
final label = channels.length <= 2
final channelLabel = channels.length <= 2
? channels.map(_humanize).join(', ')
: '${channels.length} channels';
final syncSuffix = pendingCount > 0
? ' — syncing $pendingCount item${pendingCount == 1 ? '' : 's'}'
: '';
final label = 'Reconnecting $channelLabel$syncSuffix';
return Positioned(
bottom: 16,
right: 16,
@@ -60,7 +68,7 @@ class ReconnectIndicator extends ConsumerWidget {
const SizedBox(width: 8),
Flexible(
child: Text(
'Reconnecting $label',
label,
style: Theme.of(context).textTheme.labelSmall,
overflow: TextOverflow.ellipsis,
),
+144
View File
@@ -0,0 +1,144 @@
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),
);
}
}
+53 -11
View File
@@ -3,10 +3,12 @@ import '../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/profile.dart';
import '../models/task_assignment.dart';
import '../providers/profile_provider.dart';
import '../providers/tasks_provider.dart';
import '../theme/app_surfaces.dart';
import '../utils/snackbar.dart';
import 'sync_pending_badge.dart';
class TaskAssignmentSection extends ConsumerWidget {
const TaskAssignmentSection({
@@ -30,7 +32,21 @@ class TaskAssignmentSection extends ConsumerWidget {
.where((task) => task.id == taskId)
.map((task) => task.ticketId)
.firstOrNull;
final assignments = assignmentsAsync.valueOrNull ?? [];
final liveAssignments = assignmentsAsync.valueOrNull ?? <TaskAssignment>[];
final pendingUserIds =
ref.watch(offlinePendingAssignmentsProvider)[taskId];
final assignments = pendingUserIds != null
? [
...liveAssignments.where((a) => a.taskId != taskId),
...pendingUserIds.map(
(uid) => TaskAssignment(
taskId: taskId,
userId: uid,
createdAt: DateTime.now(),
),
),
]
: liveAssignments;
final itStaff =
profiles.where((profile) => profile.role == 'it_staff').toList()
@@ -90,16 +106,42 @@ class TaskAssignmentSection extends ConsumerWidget {
final label = profile?.fullName.isNotEmpty == true
? profile!.fullName
: assignment.userId;
return InputChip(
label: Text(label),
onDeleted: canAssign
? () => ref
.read(taskAssignmentsControllerProvider)
.removeAssignment(
taskId: taskId,
userId: assignment.userId,
)
: null,
final isPendingAssignment =
pendingUserIds?.contains(assignment.userId) ?? false;
return Stack(
clipBehavior: Clip.none,
children: [
InputChip(
label: Text(label),
onDeleted: canAssign
? () async {
try {
await ref
.read(taskAssignmentsControllerProvider)
.removeAssignment(
taskId: taskId,
userId: assignment.userId,
);
} catch (e) {
if (context.mounted) {
showErrorSnackBar(
context,
'Failed to remove assignment',
);
}
}
}
: null,
),
Positioned(
top: -6,
right: -6,
child: SyncPendingBadge(
isPending: isPendingAssignment,
compact: true,
),
),
],
);
}).toList(),
),