Files
2026-05-27 03:11:45 -04:00

770 lines
24 KiB
Dart

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 = <Widget>[
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<DashboardSourceBreakdown> 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>(
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<DashboardRecentReservation> analyticsReservations;
final List<Reservation> 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<String>(
onSelected: (value) async {
if (value == 'logout') {
final confirm = await showDialog<bool>(
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<String>(
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<String>(
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('_', ' ');
}
}