import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../../core/models/analytics.dart'; import '../../../core/models/reservation.dart'; import '../../../core/providers/auth_provider.dart'; import '../../../widgets/error_view.dart'; import '../../../widgets/reservation_card.dart'; import '../../../widgets/stat_card.dart'; import '../providers/dashboard_providers.dart'; import 'dashboard_shell.dart'; class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final employee = ref.watch(authProvider).employee; final canViewRevenue = employee?.role != 'AGENT'; final metricsAsync = ref.watch(dashboardMetricsProvider); final fallbackRecentAsync = ref.watch( reservationsProvider(( page: 1, pageSize: 5, status: null, source: null, search: null, customerId: null, )), ); final onlineAsync = ref.watch( reservationsProvider(( page: 1, pageSize: 100, status: 'DRAFT', source: 'MARKETPLACE', search: null, customerId: null, )), ); final pendingOnline = onlineAsync.valueOrNull?.data.length ?? 0; final unreadCount = ref.watch(unreadCountProvider).valueOrNull ?? 0; return DashboardShell( appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Dashboard', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), Text( DateFormat('EEEE, MMMM d').format(DateTime.now()), style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant), ), ], ), actions: [ IconButton( icon: unreadCount > 0 ? Badge( label: Text('$unreadCount'), child: const Icon(Icons.notifications_outlined), ) : const Icon(Icons.notifications_outlined), onPressed: () => context.push('/dashboard/notifications'), ), _AvatarMenu(employee: employee), ], ), body: RefreshIndicator( onRefresh: () async { ref.invalidate(dashboardMetricsProvider); ref.invalidate(reservationsProvider); }, child: metricsAsync.when( loading: () => ListView( padding: const EdgeInsets.all(16), children: const [ SizedBox(height: 240, child: Center(child: CircularProgressIndicator())), ], ), error: (e, _) => ListView( padding: const EdgeInsets.all(16), children: [ ErrorView( message: 'Could not load dashboard', onRetry: () => ref.invalidate(dashboardMetricsProvider), ), ], ), data: (metrics) => ListView( padding: const EdgeInsets.all(16), children: [ if (metrics.subscription != null && metrics.subscription!.status != 'ACTIVE') Padding( padding: const EdgeInsets.only(bottom: 16), child: _SubscriptionBanner( subscription: metrics.subscription!, ), ), _KpiGrid( kpis: metrics.kpis, canViewRevenue: canViewRevenue, ), if (pendingOnline > 0) ...[ const SizedBox(height: 16), InkWell( onTap: () => context.push('/dashboard/online-reservations'), borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 14, ), decoration: BoxDecoration( color: const Color(0xFFFFF3CD), borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFFF6B00)), ), child: Row( children: [ const Icon( Icons.public, color: Color(0xFFFF6B00), size: 22, ), const SizedBox(width: 12), Expanded( child: Text( '$pendingOnline online reservation${pendingOnline == 1 ? '' : 's'} pending approval', style: const TextStyle( fontWeight: FontWeight.w600, color: Color(0xFF92400E), ), ), ), const Icon(Icons.chevron_right, color: Color(0xFFFF6B00)), ], ), ), ), ], const SizedBox(height: 16), _SourceBreakdownSection( sourceBreakdown: metrics.sourceBreakdown, canViewRevenue: canViewRevenue, ), const SizedBox(height: 16), _RecentReservationsSection( analyticsReservations: metrics.recentReservations, fallbackReservations: fallbackRecentAsync.valueOrNull?.data ?? const [], ), ], ), ), ), ); } } class _KpiGrid extends StatelessWidget { final DashboardKpis kpis; final bool canViewRevenue; const _KpiGrid({ required this.kpis, required this.canViewRevenue, }); @override Widget build(BuildContext context) { final cards = [ StatCard( title: 'Total Bookings', value: NumberFormat.decimalPattern().format(kpis.totalBookings), icon: Icons.calendar_today_outlined, color: const Color(0xFF1A56DB), change: kpis.bookingsChange, footerLabel: 'vs last month', ), StatCard( title: 'Active Vehicles', value: NumberFormat.decimalPattern().format(kpis.activeVehicles), icon: Icons.directions_car_outlined, color: const Color(0xFF0E9F6E), change: kpis.vehiclesChange, footerLabel: 'vs last month', ), if (canViewRevenue) StatCard( title: 'Monthly Revenue', value: _formatMoney(kpis.monthlyRevenue), icon: Icons.attach_money, color: const Color(0xFFFF6B00), change: kpis.revenueChange, footerLabel: 'vs last month', ), StatCard( title: 'Total Customers', value: NumberFormat.decimalPattern().format(kpis.totalCustomers), icon: Icons.people_outline, color: const Color(0xFF7C3AED), change: kpis.customersChange, footerLabel: 'vs last month', ), ]; return GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisExtent: 170, crossAxisSpacing: 12, mainAxisSpacing: 12, ), itemCount: cards.length, itemBuilder: (_, i) => cards[i], ); } } class _SourceBreakdownSection extends StatelessWidget { final List sourceBreakdown; final bool canViewRevenue; const _SourceBreakdownSection({ required this.sourceBreakdown, required this.canViewRevenue, }); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final maxCount = sourceBreakdown.isEmpty ? 1 : sourceBreakdown.map((e) => e.count).reduce(math.max); return Column( children: [ Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Booking Sources', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: cs.onSurface, ), ), const SizedBox(height: 14), if (sourceBreakdown.isEmpty) Text( 'No booking data yet.', style: TextStyle(color: cs.onSurfaceVariant), ) else ...sourceBreakdown.map( (item) => Padding( padding: const EdgeInsets.only(bottom: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( _sourceLabel(item.source), style: TextStyle( fontWeight: FontWeight.w600, color: cs.onSurface, ), ), ), Text( '${item.count} bookings', style: TextStyle(color: cs.onSurfaceVariant), ), ], ), const SizedBox(height: 8), LinearProgressIndicator( value: maxCount == 0 ? 0 : item.count / maxCount, minHeight: 8, borderRadius: BorderRadius.circular(99), backgroundColor: cs.surfaceContainerHighest, valueColor: const AlwaysStoppedAnimation( Color(0xFF1A56DB), ), ), if (canViewRevenue) ...[ const SizedBox(height: 6), Text( _formatMoney(item.revenue), style: TextStyle( fontSize: 12, color: cs.onSurfaceVariant, ), ), ], ], ), ), ), ], ), ), ), if (sourceBreakdown.isNotEmpty) ...[ const SizedBox(height: 12), Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Quick Stats', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: cs.onSurface, ), ), const SizedBox(height: 12), ...sourceBreakdown.map( (item) => Padding( padding: const EdgeInsets.only(bottom: 12), child: Row( children: [ Expanded( child: Text( _sourceLabel(item.source), style: TextStyle(color: cs.onSurfaceVariant), ), ), Text( '${item.count}', style: TextStyle( fontWeight: FontWeight.w700, color: cs.onSurface, ), ), if (canViewRevenue) ...[ const SizedBox(width: 10), Text( _formatMoney(item.revenue), style: TextStyle( fontSize: 12, color: cs.onSurfaceVariant, ), ), ], ], ), ), ), ], ), ), ), ], ], ); } } class _RecentReservationsSection extends StatelessWidget { final List analyticsReservations; final List fallbackReservations; const _RecentReservationsSection({ required this.analyticsReservations, required this.fallbackReservations, }); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Card( child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Recent Reservations', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: cs.onSurface, ), ), TextButton( onPressed: () => context.go('/dashboard/reservations'), child: const Text('View all'), ), ], ), const SizedBox(height: 8), if (analyticsReservations.isNotEmpty) ...analyticsReservations.map( (reservation) => _DashboardRecentReservationCard( reservation: reservation, ), ) else if (fallbackReservations.isNotEmpty) ...fallbackReservations.map( (reservation) => Padding( padding: const EdgeInsets.only(bottom: 10), child: ReservationCard( reservation: reservation, onTap: () => context.push('/dashboard/reservations/${reservation.id}'), ), ), ) else Padding( padding: const EdgeInsets.only(bottom: 16), child: Text( 'No reservations yet', style: TextStyle(color: cs.onSurfaceVariant), ), ), ], ), ), ); } } class _DashboardRecentReservationCard extends StatelessWidget { final DashboardRecentReservation reservation; const _DashboardRecentReservationCard({required this.reservation}); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final dateFmt = DateFormat('MMM d'); return InkWell( onTap: () => context.push('/dashboard/reservations/${reservation.id}'), borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.only(bottom: 12), child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), border: Border.all(color: cs.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( '#${reservation.bookingRef}', style: const TextStyle( fontWeight: FontWeight.w700, color: Color(0xFF1A56DB), ), ), ), _StatusPill(status: reservation.status), ], ), const SizedBox(height: 8), Text( reservation.customerName, style: TextStyle( fontWeight: FontWeight.w600, color: cs.onSurface, ), ), const SizedBox(height: 2), Text( reservation.vehicleName, style: TextStyle(color: cs.onSurfaceVariant), ), const SizedBox(height: 8), Row( children: [ Expanded( child: Text( '${dateFmt.format(reservation.startDate)} - ${DateFormat('MMM d, yyyy').format(reservation.endDate)}', style: TextStyle( fontSize: 12, color: cs.onSurfaceVariant, ), ), ), Text( _formatMoney(reservation.totalAmount), style: TextStyle( fontWeight: FontWeight.w700, color: cs.onSurface, ), ), ], ), ], ), ), ), ); } } class _SubscriptionBanner extends StatelessWidget { final DashboardSubscription subscription; const _SubscriptionBanner({required this.subscription}); @override Widget build(BuildContext context) { final trialEnds = subscription.trialEndsAt; late final Color borderColor; late final Color backgroundColor; late final Color foregroundColor; late final IconData icon; late final String message; switch (subscription.status) { case 'TRIALING': borderColor = const Color(0xFF93C5FD); backgroundColor = const Color(0xFFEFF6FF); foregroundColor = const Color(0xFF1D4ED8); icon = Icons.schedule_rounded; message = trialEnds == null ? 'Trial active' : 'Trial ends ${DateFormat('MMM d, yyyy').format(trialEnds)}'; break; case 'PAST_DUE': borderColor = const Color(0xFFFBBF24); backgroundColor = const Color(0xFFFFFBEB); foregroundColor = const Color(0xFFB45309); icon = Icons.warning_amber_rounded; message = 'Subscription payment is past due'; break; default: borderColor = const Color(0xFFFCA5A5); backgroundColor = const Color(0xFFFEF2F2); foregroundColor = const Color(0xFFB91C1C); icon = Icons.block_rounded; message = 'Subscription is suspended'; break; } return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(14), border: Border.all(color: borderColor), ), child: Row( children: [ Icon(icon, color: foregroundColor), const SizedBox(width: 10), Expanded( child: Text( message, style: TextStyle( fontWeight: FontWeight.w600, color: foregroundColor, ), ), ), ], ), ); } } class _StatusPill extends StatelessWidget { final String status; const _StatusPill({required this.status}); @override Widget build(BuildContext context) { final normalized = status.toUpperCase(); late final Color bg; late final Color fg; switch (normalized) { case 'CONFIRMED': bg = const Color(0xFFDBEAFE); fg = const Color(0xFF1D4ED8); break; case 'PENDING': case 'DRAFT': bg = const Color(0xFFFEF3C7); fg = const Color(0xFFB45309); break; case 'ACTIVE': bg = const Color(0xFFDCFCE7); fg = const Color(0xFF15803D); break; case 'CANCELLED': bg = const Color(0xFFFEE2E2); fg = const Color(0xFFB91C1C); break; default: bg = const Color(0xFFE5E7EB); fg = const Color(0xFF374151); break; } return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(999), ), child: Text( normalized, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: fg, ), ), ); } } class _AvatarMenu extends ConsumerWidget { final dynamic employee; const _AvatarMenu({required this.employee}); @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final initial = employee?.firstName.substring(0, 1).toUpperCase() ?? 'U'; final name = employee?.fullName ?? 'Employee'; final role = employee?.role ?? ''; return Padding( padding: const EdgeInsets.only(right: 8), child: PopupMenuButton( onSelected: (value) async { if (value == 'logout') { final confirm = await showDialog( context: context, builder: (_) => AlertDialog( title: const Text('Sign out'), content: const Text('Are you sure you want to sign out?'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.pop(context, true), style: TextButton.styleFrom( foregroundColor: const Color(0xFFE02424), ), child: const Text('Sign out'), ), ], ), ); if (confirm == true) { await ref.read(authProvider.notifier).logout(); if (context.mounted) context.go('/landing'); } } }, offset: const Offset(0, 44), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), itemBuilder: (_) => [ PopupMenuItem( enabled: false, padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( name, style: TextStyle( fontWeight: FontWeight.w600, color: cs.onSurface, fontSize: 14, ), ), if (role.isNotEmpty) Text( role, style: TextStyle( fontSize: 12, color: cs.onSurfaceVariant, ), ), const SizedBox(height: 4), const Divider(height: 1), ], ), ), const PopupMenuItem( value: 'logout', child: Row( children: [ Icon(Icons.logout, size: 18, color: Color(0xFFE02424)), SizedBox(width: 10), Text('Sign out', style: TextStyle(color: Color(0xFFE02424))), ], ), ), ], child: CircleAvatar( backgroundColor: const Color(0xFF1A56DB), radius: 18, child: Text( initial, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14, ), ), ), ), ); } } String _formatMoney(double amount) { return NumberFormat.currency( name: 'MAD', symbol: 'MAD ', decimalDigits: 2, ).format(amount / 100); } String _sourceLabel(String source) { switch (source.toUpperCase()) { case 'MARKETPLACE': return 'Marketplace'; case 'WALK_IN': return 'Walk-in'; case 'DIRECT': return 'Direct'; default: return source.replaceAll('_', ' '); } }