fix phone dashboard

This commit is contained in:
root
2026-05-27 03:11:45 -04:00
parent 7ca43ba2f3
commit 1112b04516
48 changed files with 1222 additions and 368 deletions
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../../../core/constants/app_constants.dart';
import '../../../widgets/app_back_button.dart';
class AgencyDashboardWebViewScreen extends StatefulWidget {
const AgencyDashboardWebViewScreen({super.key});
@@ -53,7 +54,7 @@ class _AgencyDashboardWebViewScreenState
return Scaffold(
appBar: AppBar(
title: const Text('Agency Dashboard'),
leading: BackButton(onPressed: () => context.go('/landing')),
leading: const AppBackButton(fallbackRoute: '/landing'),
actions: [
if (!_isLoading)
IconButton(
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import 'package:go_router/go_router.dart';
import '../../../core/providers/auth_provider.dart';
class EmployeeLoginScreen extends ConsumerStatefulWidget {
@@ -37,6 +38,9 @@ class _EmployeeLoginScreenState extends ConsumerState<EmployeeLoginScreen> {
_emailController.text.trim(),
_passwordController.text,
);
if (mounted) {
context.go('/dashboard');
}
} on DioException catch (e) {
setState(() {
_error = _messageForDioError(e);
@@ -60,6 +60,7 @@ class LandingScreen extends StatelessWidget {
width: double.infinity,
child: ElevatedButton(
onPressed: () => context.go('/client'),
//onPressed: () => context.push('/client'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFF6B00),
foregroundColor: Colors.white,
@@ -86,7 +87,7 @@ class LandingScreen extends StatelessWidget {
),
const SizedBox(height: 16),
TextButton(
onPressed: () => context.go('/agency'),
onPressed: () => context.push('/login/employee'),
style: TextButton.styleFrom(
foregroundColor: Colors.white.withValues(alpha: 0.5),
),
+4 -2
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../widgets/app_back_button.dart';
class RoleScreen extends StatelessWidget {
const RoleScreen({super.key});
@@ -13,7 +14,8 @@ class RoleScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 40),
const AppBackButton(fallbackRoute: '/landing'),
const SizedBox(height: 24),
Container(
width: 60,
height: 60,
@@ -53,7 +55,7 @@ class RoleScreen extends StatelessWidget {
title: 'Browse & Reserve',
subtitle: 'Find and book a vehicle',
color: const Color(0xFF0E9F6E),
onTap: () => context.go('/client'),
onTap: () => context.push('/client'),
),
],
),
+1 -1
View File
@@ -34,7 +34,7 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
),
const SizedBox(height: 20),
const Text(
'RentalDriveGo',
'KriTomobil',
style: TextStyle(
color: Colors.white,
fontSize: 28,
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../widgets/app_back_button.dart';
import '../models/booking_result.dart';
const _orange = Color(0xFFFF6B00);
@@ -18,6 +19,7 @@ class BookingConfirmationScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(
leading: const AppBackButton(fallbackRoute: '/client'),
title: const Text('Booking Confirmed'),
),
body: SingleChildScrollView(
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../core/models/marketplace.dart';
import '../../../widgets/app_back_button.dart';
import '../models/booking_result.dart';
import '../providers/client_providers.dart';
@@ -130,7 +131,10 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
: null;
return Scaffold(
appBar: AppBar(title: const Text('Complete Booking')),
appBar: AppBar(
leading: const AppBackButton(fallbackRoute: '/client'),
title: const Text('Complete Booking'),
),
body: Form(
key: _formKey,
child: ListView(
@@ -6,6 +6,7 @@ import 'package:intl/intl.dart';
import '../providers/client_providers.dart';
import '../../../core/models/marketplace.dart';
import '../../../l10n/app_localizations.dart';
import '../../../widgets/app_back_button.dart';
import '../../../widgets/preferences_bar.dart';
import 'client_shell.dart';
import 'widgets/marketplace_vehicle_card.dart';
@@ -233,15 +234,21 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
padding: const EdgeInsets.fromLTRB(20, 16, 12, 0),
child: Row(
children: [
Text(
l10n.findCarToRent,
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight: FontWeight.bold,
AppBackButton(
fallbackRoute: '/landing',
color: cs.onSurface,
),
const SizedBox(width: 4),
Expanded(
child: Text(
l10n.findCarToRent,
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
const Spacer(),
const PreferencesBar(),
],
),
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../core/models/marketplace.dart';
import '../../../core/constants/app_constants.dart';
import '../../../widgets/app_back_button.dart';
import '../providers/client_providers.dart';
class ClientVehicleDetailScreen extends ConsumerWidget {
@@ -48,6 +49,7 @@ class _DetailView extends StatelessWidget {
SliverAppBar(
expandedHeight: 280,
pinned: true,
leading: const AppBackButton(fallbackRoute: '/client'),
flexibleSpace: FlexibleSpaceBar(
background: vehicle.primaryPhoto != null
? CachedNetworkImage(
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../widgets/app_back_button.dart';
import 'client_shell.dart';
class MyBookingsScreen extends ConsumerWidget {
@@ -13,7 +14,10 @@ class MyBookingsScreen extends ConsumerWidget {
if (auth.status != AuthStatus.authenticated) {
return ClientShell(
appBar: AppBar(title: const Text('My Bookings')),
appBar: AppBar(
leading: const AppBackButton(fallbackRoute: '/client'),
title: const Text('My Bookings'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
@@ -53,7 +57,10 @@ class MyBookingsScreen extends ConsumerWidget {
}
return ClientShell(
appBar: AppBar(title: const Text('My Bookings')),
appBar: AppBar(
leading: const AppBackButton(fallbackRoute: '/client'),
title: const Text('My Bookings'),
),
body: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -34,24 +34,35 @@ final dashboardMetricsProvider = FutureProvider<DashboardMetrics>((ref) async {
return ref.read(analyticsServiceProvider).getDashboard();
});
typedef ReportQuery = ({
String startDate,
String endDate,
String? status,
});
final reportProvider =
FutureProvider.family<ReportSummary, Map<String, String>>((ref, params) async {
FutureProvider.family<ReportSummary, ReportQuery>((ref, params) async {
return ref.read(analyticsServiceProvider).getReport(
startDate: params['startDate']!,
endDate: params['endDate']!,
status: params['status'],
startDate: params.startDate,
endDate: params.endDate,
status: params.status,
);
});
// ─── Vehicles ─────────────────────────────────────────────────
final vehiclesProvider = FutureProvider.family<VehicleListResponse, Map<String, dynamic>>(
typedef VehicleQuery = ({
int page,
int pageSize,
String? status,
});
final vehiclesProvider = FutureProvider.family<VehicleListResponse, VehicleQuery>(
(ref, params) async {
return ref.read(vehicleServiceProvider).getVehicles(
page: params['page'] as int? ?? 1,
pageSize: params['pageSize'] as int? ?? 20,
status: params['status'] as String?,
search: params['search'] as String?,
page: params.page,
pageSize: params.pageSize,
status: params.status,
);
},
);
@@ -75,16 +86,25 @@ final calendarProvider =
// ─── Reservations ─────────────────────────────────────────────
typedef ReservationQuery = ({
int page,
int pageSize,
String? status,
String? source,
String? search,
String? customerId,
});
final reservationsProvider =
FutureProvider.family<ReservationListResponse, Map<String, dynamic>>(
FutureProvider.family<ReservationListResponse, ReservationQuery>(
(ref, params) async {
return ref.read(reservationServiceProvider).getReservations(
page: params['page'] as int? ?? 1,
pageSize: params['pageSize'] as int? ?? 20,
status: params['status'] as String?,
source: params['source'] as String?,
search: params['search'] as String?,
customerId: params['customerId'] as String?,
page: params.page,
pageSize: params.pageSize,
status: params.status,
source: params.source,
search: params.search,
customerId: params.customerId,
);
},
);
@@ -101,13 +121,19 @@ final inspectionsProvider =
// ─── Customers ────────────────────────────────────────────────
typedef CustomerQuery = ({
int page,
int pageSize,
String? search,
});
final customersProvider =
FutureProvider.family<CustomerListResponse, Map<String, dynamic>>(
FutureProvider.family<CustomerListResponse, CustomerQuery>(
(ref, params) async {
return ref.read(customerServiceProvider).getCustomers(
page: params['page'] as int? ?? 1,
pageSize: params['pageSize'] as int? ?? 20,
search: params['search'] as String?,
page: params.page,
pageSize: params.pageSize,
search: params.search,
);
},
);
@@ -15,6 +15,33 @@ class _ContractsScreenState extends ConsumerState<ContractsScreen> {
final _searchCtrl = TextEditingController();
String _search = '';
List<Reservation> _visibleContracts(List<Reservation> reservations) {
final query = _search.trim().toLowerCase();
return reservations.where((reservation) {
if (reservation.contractNumber == null) {
return false;
}
if (query.isEmpty) {
return true;
}
final customer = reservation.customer;
final vehicle = reservation.vehicle;
final haystack = [
customer?.fullName,
customer?.email,
vehicle?.displayName,
vehicle?.licensePlate,
reservation.contractNumber,
reservation.invoiceNumber,
].whereType<String>().join(' ').toLowerCase();
return haystack.contains(query);
}).toList();
}
@override
void dispose() {
_searchCtrl.dispose();
@@ -23,12 +50,14 @@ class _ContractsScreenState extends ConsumerState<ContractsScreen> {
@override
Widget build(BuildContext context) {
final params = {
'page': 1,
'pageSize': 100,
'status': 'CONFIRMED,ACTIVE,COMPLETED,CLOSED',
if (_search.isNotEmpty) 'search': _search,
};
final ReservationQuery params = (
page: 1,
pageSize: 100,
status: null,
source: null,
search: null,
customerId: null,
);
final asyncData = ref.watch(reservationsProvider(params));
@@ -69,9 +98,7 @@ class _ContractsScreenState extends ConsumerState<ContractsScreen> {
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (response) {
final contracts = response.data
.where((r) => r.contractNumber != null)
.toList();
final contracts = _visibleContracts(response.data);
if (contracts.isEmpty) {
return const Center(
@@ -17,7 +17,14 @@ class CustomerDetailScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(customerDetailProvider(id));
final reservationsAsync = ref.watch(
reservationsProvider({'customerId': id, 'page': 1, 'pageSize': 10}),
reservationsProvider((
page: 1,
pageSize: 10,
status: null,
source: null,
search: null,
customerId: id,
)),
);
return async.when(
@@ -25,11 +25,11 @@ class _CustomersScreenState extends ConsumerState<CustomersScreen> {
super.dispose();
}
Map<String, dynamic> get _params => {
'page': 1,
'pageSize': 50,
if (_search.isNotEmpty) 'search': _search,
};
CustomerQuery get _params => (
page: 1,
pageSize: 50,
search: _search.isNotEmpty ? _search : null,
);
@override
Widget build(BuildContext context) {
@@ -24,7 +24,10 @@ class DashboardShell extends ConsumerWidget {
final employee = ref.watch(authProvider).employee;
int currentIndex = 0;
if (location.startsWith('/dashboard/vehicles')) currentIndex = 1;
if (location.startsWith('/dashboard/fleet') ||
location.startsWith('/dashboard/vehicles')) {
currentIndex = 1;
}
if (location.startsWith('/dashboard/reservations')) currentIndex = 2;
if (location.startsWith('/dashboard/customers')) currentIndex = 3;
@@ -37,15 +40,14 @@ class DashboardShell extends ConsumerWidget {
bottomNavigationBar: NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: (i) {
switch (i) {
case 0:
context.go('/dashboard');
case 1:
context.go('/dashboard/vehicles');
case 2:
context.go('/dashboard/reservations');
case 3:
context.go('/dashboard/customers');
if (i == 0) {
context.go('/dashboard');
} else if (i == 1) {
context.go('/dashboard/fleet');
} else if (i == 2) {
context.go('/dashboard/reservations');
} else if (i == 3) {
context.go('/dashboard/customers');
}
},
destinations: const [
@@ -57,7 +59,7 @@ class DashboardShell extends ConsumerWidget {
NavigationDestination(
icon: Icon(Icons.directions_car_outlined),
selectedIcon: Icon(Icons.directions_car),
label: 'Vehicles',
label: 'Fleet',
),
NavigationDestination(
icon: Icon(Icons.event_note_outlined),
@@ -141,10 +143,10 @@ class _Drawer extends ConsumerWidget {
),
_NavItem(
icon: Icons.directions_car_outlined,
label: 'Vehicles',
label: 'Fleet',
onTap: () {
Navigator.pop(context);
context.go('/dashboard/vehicles');
context.go('/dashboard/fleet');
},
),
_NavItem(
@@ -166,7 +168,7 @@ class _Drawer extends ConsumerWidget {
const Divider(),
_NavItem(
icon: Icons.public_outlined,
label: 'Online Requests',
label: 'Online Reservations',
onTap: () {
Navigator.pop(context);
context.push('/dashboard/online-reservations');
+577 -165
View File
@@ -1,31 +1,47 @@
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 '../providers/dashboard_providers.dart';
import 'dashboard_shell.dart';
import '../../../widgets/stat_card.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 recentAsync = ref.watch(
reservationsProvider({'page': 1, 'pageSize': 5}),
final fallbackRecentAsync = ref.watch(
reservationsProvider((
page: 1,
pageSize: 5,
status: null,
source: null,
search: null,
customerId: null,
)),
);
final onlineAsync = ref.watch(
reservationsProvider({
'status': 'DRAFT',
'source': 'MARKETPLACE',
'page': 1,
'pageSize': 100,
}),
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;
@@ -35,13 +51,13 @@ class HomeScreen extends ConsumerWidget {
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Hello, ${employee?.firstName ?? 'there'}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
const Text(
'Dashboard',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
Text(
DateFormat('EEEE, MMMM d').format(DateTime.now()),
style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280)),
style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant),
),
],
),
@@ -63,97 +79,290 @@ class HomeScreen extends ConsumerWidget {
ref.invalidate(dashboardMetricsProvider);
ref.invalidate(reservationsProvider);
},
child: ListView(
padding: const EdgeInsets.all(16),
children: [
metricsAsync.when(
loading: () => const SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => ErrorView(
message: 'Could not load metrics',
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) => Column(
children: [
Row(
children: [
Expanded(
child: StatCard(
title: 'Total Vehicles',
value: '${metrics.totalVehicles}',
icon: Icons.directions_car,
color: const Color(0xFF1A56DB),
subtitle: '${metrics.availableVehicles} available',
),
),
const SizedBox(width: 12),
Expanded(
child: StatCard(
title: 'Active Rentals',
value: '${metrics.activeReservations}',
icon: Icons.event_available,
color: const Color(0xFF0E9F6E),
),
),
],
],
),
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!,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: StatCard(
title: 'Revenue',
value:
'\$${NumberFormat.compact().format(metrics.totalRevenue / 100)}',
icon: Icons.attach_money,
color: const Color(0xFF5521B5),
),
_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: StatCard(
title: 'Customers',
value: '${metrics.totalCustomers}',
icon: Icons.people,
color: const Color(0xFFB45309),
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: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
),
],
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: [
const Text(
'Fleet Occupancy',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFF374151),
),
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: 12),
const SizedBox(height: 8),
LinearProgressIndicator(
value: metrics.occupancyRate / 100,
backgroundColor: const Color(0xFFE5E7EB),
value: maxCount == 0 ? 0 : item.count / maxCount,
minHeight: 8,
borderRadius: BorderRadius.circular(99),
backgroundColor: cs.surfaceContainerHighest,
valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFF1A56DB),
),
minHeight: 8,
borderRadius: BorderRadius.circular(4),
),
const SizedBox(height: 8),
Text(
'${metrics.occupancyRate.toStringAsFixed(1)}% occupied',
style: const TextStyle(
fontSize: 13,
color: Color(0xFF6B7280),
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,
),
),
],
],
),
),
@@ -161,54 +370,41 @@ class HomeScreen extends ConsumerWidget {
],
),
),
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 request${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: 24),
),
],
],
);
}
}
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: [
const Text(
Text(
'Recent Reservations',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF111928),
fontWeight: FontWeight.w700,
color: cs.onSurface,
),
),
TextButton(
@@ -218,37 +414,31 @@ class HomeScreen extends ConsumerWidget {
],
),
const SizedBox(height: 8),
recentAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => const ErrorView(
message: 'Could not load recent reservations',
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),
),
),
data: (res) => res.data.isEmpty
? const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'No reservations yet',
style: TextStyle(color: Color(0xFF9CA3AF)),
),
),
)
: Column(
children: res.data
.map(
(r) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ReservationCard(
reservation: r,
onTap: () => context.push(
'/dashboard/reservations/${r.id}',
),
),
),
)
.toList(),
),
),
],
),
),
@@ -256,6 +446,206 @@ class HomeScreen extends ConsumerWidget {
}
}
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;
@@ -263,6 +653,7 @@ class _AvatarMenu extends ConsumerWidget {
@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 ?? '';
@@ -309,18 +700,18 @@ class _AvatarMenu extends ConsumerWidget {
children: [
Text(
name,
style: const TextStyle(
style: TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFF111928),
color: cs.onSurface,
fontSize: 14,
),
),
if (role.isNotEmpty)
Text(
role,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
color: cs.onSurfaceVariant,
),
),
const SizedBox(height: 4),
@@ -355,3 +746,24 @@ class _AvatarMenu extends ConsumerWidget {
);
}
}
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('_', ' ');
}
}
@@ -6,12 +6,14 @@ import '../../../core/models/reservation.dart';
import '../../../widgets/error_view.dart';
import '../../../widgets/status_badge.dart';
const _params = {
'status': 'DRAFT',
'source': 'MARKETPLACE',
'page': 1,
'pageSize': 100,
};
const ReservationQuery _params = (
status: 'DRAFT',
source: 'MARKETPLACE',
page: 1,
pageSize: 100,
search: null,
customerId: null,
);
class OnlineReservationsScreen extends ConsumerWidget {
const OnlineReservationsScreen({super.key});
@@ -16,12 +16,12 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
DateTime _end = DateTime.now();
String? _statusFilter;
Map<String, String> get _params => {
'startDate':
ReportQuery get _params => (
startDate:
'${_start.toIso8601String().substring(0, 10)}T00:00:00.000Z',
'endDate': '${_end.toIso8601String().substring(0, 10)}T23:59:59.000Z',
'status': ?_statusFilter,
};
endDate: '${_end.toIso8601String().substring(0, 10)}T23:59:59.000Z',
status: _statusFilter,
);
Future<void> _pickRange() async {
final range = await showDateRangePicker(
@@ -52,12 +52,14 @@ class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
super.dispose();
}
Map<String, dynamic> _params(int tabIndex) => {
'page': 1,
'pageSize': 50,
if (_statuses[tabIndex] != null) 'status': _statuses[tabIndex],
if (_search.isNotEmpty) 'search': _search,
};
ReservationQuery _params(int tabIndex) => (
page: 1,
pageSize: 50,
status: _statuses[tabIndex],
source: null,
search: _search.isNotEmpty ? _search : null,
customerId: null,
);
@override
Widget build(BuildContext context) {
@@ -116,7 +118,7 @@ class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
}
class _ReservationTab extends ConsumerWidget {
final Map<String, dynamic> params;
final ReservationQuery params;
final void Function(String id) onTap;
const _ReservationTab({required this.params, required this.onTap});
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:dio/dio.dart';
import '../../../core/models/vehicle.dart';
import '../providers/dashboard_providers.dart';
import '../../../widgets/vehicle_card.dart';
import '../../../widgets/loading_list.dart';
@@ -26,12 +28,45 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
super.dispose();
}
Map<String, dynamic> get _params => {
'page': 1,
'pageSize': 50,
if (_statusFilter != null) 'status': _statusFilter,
if (_search.isNotEmpty) 'search': _search,
};
VehicleQuery get _params => (
page: 1,
pageSize: 100,
status: null,
);
List<Vehicle> _filterVehicles(List<Vehicle> vehicles) {
final query = _search.trim().toLowerCase();
final status = _statusFilter;
return vehicles.where((vehicle) {
if (status != null && vehicle.status != status) {
return false;
}
if (query.isEmpty) {
return true;
}
final haystack = [
vehicle.displayName,
vehicle.licensePlate,
vehicle.status,
vehicle.category,
].join(' ').toLowerCase();
return haystack.contains(query);
}).toList();
}
String _errorMessage(Object error) {
if (error is DioException) {
final data = error.response?.data;
if (data is Map<String, dynamic>) {
final message = data['message'];
if (message is String && message.isNotEmpty) {
return message;
}
}
}
return 'Failed to load vehicles';
}
@override
Widget build(BuildContext context) {
@@ -48,7 +83,7 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
child: const Icon(Icons.add),
),
appBar: AppBar(
title: const Text('Vehicles'),
title: const Text('Fleet'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Padding(
@@ -86,34 +121,40 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
body: vehiclesAsync.when(
loading: () => const LoadingList(itemHeight: 240),
error: (e, _) => ErrorView(
message: 'Failed to load vehicles',
message: _errorMessage(e),
onRetry: () => ref.invalidate(vehiclesProvider(_params)),
),
data: (res) => res.data.isEmpty
? const Center(
data: (res) {
final filteredVehicles = _filterVehicles(res.data);
return filteredVehicles.isEmpty
? const Center(
child: Text(
'No vehicles found',
style: TextStyle(color: Color(0xFF9CA3AF)),
),
)
: RefreshIndicator(
onRefresh: () async =>
ref.invalidate(vehiclesProvider(_params)),
child: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 1,
mainAxisExtent: 240,
mainAxisSpacing: 12,
: RefreshIndicator(
onRefresh: () async =>
ref.invalidate(vehiclesProvider(_params)),
child: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 1,
mainAxisExtent: 240,
mainAxisSpacing: 12,
),
itemCount: filteredVehicles.length,
itemBuilder: (_, i) => VehicleCard(
vehicle: filteredVehicles[i],
onTap: () => context.push(
'/dashboard/fleet/${filteredVehicles[i].id}',
),
),
),
itemCount: res.data.length,
itemBuilder: (_, i) => VehicleCard(
vehicle: res.data[i],
onTap: () =>
context.push('/dashboard/vehicles/${res.data[i].id}'),
),
),
),
);
},
),
);
}