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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user