Offline rediness
This commit is contained in:
@@ -10,6 +10,7 @@ import '../providers/notifications_provider.dart';
|
||||
import '../providers/profile_provider.dart';
|
||||
import 'announcement_banner.dart';
|
||||
import 'app_breakpoints.dart';
|
||||
import 'offline_readiness_sheet.dart';
|
||||
import 'profile_avatar.dart';
|
||||
import 'pass_slip_countdown_banner.dart';
|
||||
import 'shift_countdown_banner.dart';
|
||||
@@ -600,6 +601,7 @@ Future<void> _showOverflowSheet(
|
||||
List<NavItem> items,
|
||||
VoidCallback onLogout,
|
||||
) async {
|
||||
final parentContext = context;
|
||||
await m3ShowBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
@@ -616,6 +618,18 @@ Future<void> _showOverflowSheet(
|
||||
item.onTap(context, onLogout: onLogout);
|
||||
},
|
||||
),
|
||||
if (!kIsWeb) ...[
|
||||
const Divider(height: 16),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.offline_bolt_rounded),
|
||||
title: const Text('Offline Readiness'),
|
||||
subtitle: const Text('Cache status & pending sync'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
showOfflineReadinessSheet(parentContext);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
+118
-84
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../brick/cache_warmer.dart';
|
||||
import '../providers/connectivity_provider.dart';
|
||||
import '../providers/offline_readiness_provider.dart';
|
||||
import '../providers/supabase_provider.dart';
|
||||
import '../providers/sync_queue_provider.dart';
|
||||
|
||||
void showOfflineReadinessSheet(BuildContext context) {
|
||||
final container = ProviderScope.containerOf(context);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const _OfflineReadinessSheet(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _OfflineReadinessSheet extends ConsumerStatefulWidget {
|
||||
const _OfflineReadinessSheet();
|
||||
|
||||
@override
|
||||
ConsumerState<_OfflineReadinessSheet> createState() => _OfflineReadinessSheetState();
|
||||
}
|
||||
|
||||
class _OfflineReadinessSheetState extends ConsumerState<_OfflineReadinessSheet> {
|
||||
bool _refreshing = false;
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (_refreshing) return;
|
||||
setState(() => _refreshing = true);
|
||||
try {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
await CacheWarmer.warmAll(client);
|
||||
ref.invalidate(offlineReadinessProvider);
|
||||
} finally {
|
||||
if (mounted) setState(() => _refreshing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final readiness = ref.watch(offlineReadinessProvider);
|
||||
final isOnline = ref.watch(isOnlineProvider);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.92,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
// Handle bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.onSurfaceVariant.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.offline_bolt_rounded, color: scheme.primary, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Offline Readiness',
|
||||
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Spacer(),
|
||||
if (isOnline)
|
||||
_refreshing
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: scheme.primary),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
tooltip: 'Refresh cache',
|
||||
onPressed: _refresh,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Body
|
||||
Expanded(
|
||||
child: readiness.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Text('Unable to load status', style: TextStyle(color: scheme.error)),
|
||||
),
|
||||
data: (state) => _ReadinessBody(
|
||||
state: state,
|
||||
isOnline: isOnline,
|
||||
scrollController: scrollController,
|
||||
onRefresh: _refresh,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReadinessBody extends ConsumerWidget {
|
||||
final OfflineReadinessState state;
|
||||
final bool isOnline;
|
||||
final ScrollController scrollController;
|
||||
final VoidCallback onRefresh;
|
||||
|
||||
const _ReadinessBody({
|
||||
required this.state,
|
||||
required this.isOnline,
|
||||
required this.scrollController,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final breakdown = ref.watch(pendingSyncBreakdownProvider);
|
||||
|
||||
return ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
|
||||
children: [
|
||||
// Overall status chip
|
||||
_StatusChip(isReady: state.isReady, isOnline: isOnline),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Cached data section
|
||||
Text(
|
||||
'Cached Data',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_CacheGrid(counts: state.cacheCounts),
|
||||
|
||||
// Pending sync section — shown whenever any module has pending items
|
||||
if (breakdown.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Pending Sync',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_PendingList(breakdown: breakdown, isOnline: isOnline),
|
||||
],
|
||||
|
||||
// Prompt to go online if not ready and not warmed
|
||||
if (!state.hasWarmedOnce && !isOnline) ...[
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.errorContainer.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline_rounded, color: scheme.error, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Connect to the internet at least once to download offline data.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: scheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (!isOnline && state.hasWarmedOnce) ...[
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: null,
|
||||
icon: const Icon(Icons.cloud_off_rounded, size: 18),
|
||||
label: const Text('Connect to refresh cache'),
|
||||
),
|
||||
] else if (isOnline) ...[
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Refresh Cache Now'),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
final bool isReady;
|
||||
final bool isOnline;
|
||||
|
||||
const _StatusChip({required this.isReady, required this.isOnline});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final ready = isReady;
|
||||
final bg = ready ? scheme.primaryContainer : scheme.errorContainer;
|
||||
final fg = ready ? scheme.onPrimaryContainer : scheme.onErrorContainer;
|
||||
final icon = ready ? Icons.check_circle_rounded : Icons.warning_amber_rounded;
|
||||
final label = ready ? 'Ready for offline use' : 'Cache incomplete — go online to set up';
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Container(
|
||||
key: ValueKey(ready),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(12)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: fg, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(label, style: TextStyle(color: fg, fontWeight: FontWeight.w500)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheGrid extends StatelessWidget {
|
||||
final Map<String, int> counts;
|
||||
|
||||
const _CacheGrid({required this.counts});
|
||||
|
||||
static const _groups = [
|
||||
('Tasks', 'tasks', Icons.task_alt_rounded),
|
||||
('Tickets', 'tickets', Icons.confirmation_number_rounded),
|
||||
('Announcements', 'announcements', Icons.campaign_rounded),
|
||||
('Attendance', 'attendance_logs', Icons.fingerprint_rounded),
|
||||
('Schedules', 'duty_schedules', Icons.calendar_month_rounded),
|
||||
('Pass Slips', 'pass_slips', Icons.badge_rounded),
|
||||
('Leaves', 'leave_of_absence', Icons.beach_access_rounded),
|
||||
('Profiles', 'profiles', Icons.people_rounded),
|
||||
('Teams', 'teams', Icons.groups_rounded),
|
||||
('IT Requests', 'it_service_requests', Icons.computer_rounded),
|
||||
('Notifications', 'notifications', Icons.notifications_rounded),
|
||||
('Offices', 'offices', Icons.business_rounded),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 1.1,
|
||||
),
|
||||
itemCount: _groups.length,
|
||||
itemBuilder: (context, i) {
|
||||
final (label, key, icon) = _groups[i];
|
||||
final count = counts[key] ?? -1;
|
||||
final cached = count >= 0;
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
child: _CacheTile(
|
||||
key: ValueKey('$key-$count'),
|
||||
label: label,
|
||||
icon: icon,
|
||||
count: count,
|
||||
cached: cached,
|
||||
scheme: scheme,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheTile extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final int count;
|
||||
final bool cached;
|
||||
final ColorScheme scheme;
|
||||
|
||||
const _CacheTile({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.count,
|
||||
required this.cached,
|
||||
required this.scheme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bg = cached ? scheme.primaryContainer.withValues(alpha: 0.5) : scheme.surfaceContainerHighest;
|
||||
final fg = cached ? scheme.onPrimaryContainer : scheme.onSurfaceVariant;
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: fg, size: 22),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: fg, fontSize: 10, fontWeight: FontWeight.w500),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
cached ? '$count items' : 'Not cached',
|
||||
style: TextStyle(color: fg.withValues(alpha: 0.7), fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingList extends StatelessWidget {
|
||||
final Map<String, int> breakdown;
|
||||
final bool isOnline;
|
||||
|
||||
const _PendingList({required this.breakdown, required this.isOnline});
|
||||
|
||||
static const _icons = <String, IconData>{
|
||||
'tasks': Icons.task_alt_rounded,
|
||||
'tickets': Icons.confirmation_number_rounded,
|
||||
'messages': Icons.chat_bubble_rounded,
|
||||
'assignments': Icons.assignment_ind_rounded,
|
||||
'activity_logs': Icons.history_rounded,
|
||||
'check_ins': Icons.login_rounded,
|
||||
'attendance': Icons.fingerprint_rounded,
|
||||
'leaves': Icons.beach_access_rounded,
|
||||
'pass_slips': Icons.badge_rounded,
|
||||
'announcements': Icons.campaign_rounded,
|
||||
'it_requests': Icons.computer_rounded,
|
||||
'notification_reads': Icons.notifications_rounded,
|
||||
'profile_updates': Icons.person_rounded,
|
||||
'office_changes': Icons.business_rounded,
|
||||
};
|
||||
|
||||
static String _label(String key, int count) {
|
||||
final n = count.toString();
|
||||
final s = count == 1 ? '' : 's';
|
||||
switch (key) {
|
||||
case 'tasks': return '$n task$s';
|
||||
case 'tickets': return '$n ticket$s';
|
||||
case 'messages': return '$n message$s';
|
||||
case 'assignments': return '$n assignment$s';
|
||||
case 'activity_logs': return '$n activity log$s';
|
||||
case 'check_ins': return '$n check-in$s';
|
||||
case 'attendance': return '$n attendance update$s';
|
||||
case 'leaves': return '$n leave$s';
|
||||
case 'pass_slips': return '$n pass slip$s';
|
||||
case 'announcements': return '$n announcement$s';
|
||||
case 'it_requests': return '$n IT request$s';
|
||||
case 'notification_reads': return '$n notification read$s';
|
||||
case 'profile_updates': return '$n profile update$s';
|
||||
case 'office_changes': return '$n office change$s';
|
||||
default: return '$n $key';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final syncing = isOnline && breakdown.isNotEmpty;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
for (int i = 0; i < breakdown.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 6),
|
||||
_PendingTile(
|
||||
icon: _icons[breakdown.keys.elementAt(i)] ?? Icons.sync_rounded,
|
||||
label: _label(breakdown.keys.elementAt(i), breakdown.values.elementAt(i)),
|
||||
syncing: syncing,
|
||||
scheme: scheme,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool syncing;
|
||||
final ColorScheme scheme;
|
||||
|
||||
const _PendingTile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.syncing,
|
||||
required this.scheme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bg = syncing ? scheme.tertiaryContainer : scheme.surfaceContainerHighest;
|
||||
final fg = syncing ? scheme.onTertiaryContainer : scheme.onSurfaceVariant;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(10)),
|
||||
child: Row(
|
||||
children: [
|
||||
syncing ? _SpinningIcon(icon: icon, color: fg) : Icon(icon, color: fg, size: 18),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(label, style: TextStyle(color: fg, fontWeight: FontWeight.w500)),
|
||||
),
|
||||
if (syncing)
|
||||
Text('Syncing…', style: TextStyle(color: fg.withValues(alpha: 0.7), fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpinningIcon extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _SpinningIcon({required this.icon, required this.color});
|
||||
|
||||
@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: 18),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user