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
+1 -1
View File
@@ -3,7 +3,7 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.CAMERA"/>
<application
android:label="car_rental_app"
android:label="KriTomobil"
android:name="${applicationName}"
android:icon="@mipmap/launcher_icon">
<activity
+10
View File
@@ -1,2 +1,12 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.defaults.buildfeatures.resvalues=true
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
android.enableAppCompileTimeRClass=false
android.usesSdkInManifest.disallowed=false
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.r8.optimizedResourceShrinking=false
android.builtInKotlin=false
android.newDsl=false
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip
+1 -1
View File
@@ -19,7 +19,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("com.android.application") version "9.2.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
+2 -2
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Car Rental App</string>
<string>KriTomobil</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>car_rental_app</string>
<string>KriTomobil</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
+1 -1
View File
@@ -17,7 +17,7 @@ class CarRentalApp extends ConsumerWidget {
final themeMode = ref.watch(themeProvider);
return MaterialApp.router(
title: 'RentalDriveGo',
title: 'KriTomobil',
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: themeMode,
+3 -5
View File
@@ -8,7 +8,7 @@ class AppConstants {
// Production
static const String productionApiUrl = 'https://api.rentaldrivego.ma/api/v1';
static const String productionDashboardUrl = 'https://rentaldrivego.ma/dashboard';
static const String productionDashboardUrl = 'https://rentaldrivego.ma';
// Development
static const String devAndroidApiUrl = 'http://10.0.2.2:4000/api/v1';
@@ -18,14 +18,12 @@ class AppConstants {
static String get baseUrl {
if (apiBaseUrlOverride.isNotEmpty) return apiBaseUrlOverride;
if (Platform.isAndroid) return devAndroidApiUrl;
return devLocalApiUrl;
return productionApiUrl;
}
static String get dashboardBaseUrl {
if (dashboardUrlOverride.isNotEmpty) return dashboardUrlOverride;
if (Platform.isAndroid) return devAndroidDashboardUrl;
return devLocalDashboardUrl;
return productionDashboardUrl;
}
static String resolveMediaUrl(String url) {
+171 -32
View File
@@ -1,40 +1,179 @@
class DashboardMetrics {
final int totalReservations;
final int activeReservations;
final double totalRevenue;
final int totalVehicles;
final int availableVehicles;
final int totalCustomers;
final double occupancyRate;
final DashboardKpis kpis;
final List<DashboardRecentReservation> recentReservations;
final List<DashboardSourceBreakdown> sourceBreakdown;
final DashboardSubscription? subscription;
const DashboardMetrics({
required this.totalReservations,
required this.activeReservations,
required this.totalRevenue,
required this.totalVehicles,
required this.availableVehicles,
required this.totalCustomers,
required this.occupancyRate,
required this.kpis,
required this.recentReservations,
required this.sourceBreakdown,
required this.subscription,
});
factory DashboardMetrics.fromJson(Map<String, dynamic> json) =>
DashboardMetrics(
totalReservations: json['totalReservations'] as int? ?? 0,
activeReservations: json['activeReservations'] as int? ?? 0,
totalRevenue: (json['totalRevenue'] as num?)?.toDouble() ?? 0,
totalVehicles: json['totalVehicles'] as int? ?? 0,
availableVehicles: json['availableVehicles'] as int? ?? 0,
totalCustomers: json['totalCustomers'] as int? ?? 0,
occupancyRate: (json['occupancyRate'] as num?)?.toDouble() ?? 0,
);
factory DashboardMetrics.fromJson(Map<String, dynamic> json) {
final kpiJson = json['kpis'] as Map<String, dynamic>?;
factory DashboardMetrics.empty() => const DashboardMetrics(
totalReservations: 0,
activeReservations: 0,
totalRevenue: 0,
totalVehicles: 0,
availableVehicles: 0,
totalCustomers: 0,
occupancyRate: 0,
return DashboardMetrics(
kpis: DashboardKpis.fromJson(kpiJson ?? json),
recentReservations: (json['recentReservations'] as List<dynamic>?)
?.map(
(e) => DashboardRecentReservation.fromJson(
e as Map<String, dynamic>,
),
)
.toList() ??
const [],
sourceBreakdown: (json['sourceBreakdown'] as List<dynamic>?)
?.map(
(e) => DashboardSourceBreakdown.fromJson(
e as Map<String, dynamic>,
),
)
.toList() ??
const [],
subscription: json['subscription'] is Map<String, dynamic>
? DashboardSubscription.fromJson(
json['subscription'] as Map<String, dynamic>,
)
: null,
);
}
factory DashboardMetrics.empty() => DashboardMetrics(
kpis: DashboardKpis.empty(),
recentReservations: const [],
sourceBreakdown: const [],
subscription: null,
);
}
class DashboardKpis {
final int totalBookings;
final int activeVehicles;
final double monthlyRevenue;
final int totalCustomers;
final double bookingsChange;
final double vehiclesChange;
final double revenueChange;
final double customersChange;
const DashboardKpis({
required this.totalBookings,
required this.activeVehicles,
required this.monthlyRevenue,
required this.totalCustomers,
required this.bookingsChange,
required this.vehiclesChange,
required this.revenueChange,
required this.customersChange,
});
factory DashboardKpis.fromJson(Map<String, dynamic> json) => DashboardKpis(
totalBookings:
(json['totalBookings'] as num?)?.toInt() ??
(json['totalReservations'] as num?)?.toInt() ??
0,
activeVehicles:
(json['activeVehicles'] as num?)?.toInt() ??
(json['availableVehicles'] as num?)?.toInt() ??
(json['totalVehicles'] as num?)?.toInt() ??
0,
monthlyRevenue:
(json['monthlyRevenue'] as num?)?.toDouble() ??
(json['totalRevenue'] as num?)?.toDouble() ??
0,
totalCustomers: (json['totalCustomers'] as num?)?.toInt() ?? 0,
bookingsChange: (json['bookingsChange'] as num?)?.toDouble() ?? 0,
vehiclesChange: (json['vehiclesChange'] as num?)?.toDouble() ?? 0,
revenueChange: (json['revenueChange'] as num?)?.toDouble() ?? 0,
customersChange: (json['customersChange'] as num?)?.toDouble() ?? 0,
);
factory DashboardKpis.empty() => const DashboardKpis(
totalBookings: 0,
activeVehicles: 0,
monthlyRevenue: 0,
totalCustomers: 0,
bookingsChange: 0,
vehiclesChange: 0,
revenueChange: 0,
customersChange: 0,
);
}
class DashboardRecentReservation {
final String id;
final String bookingRef;
final String customerName;
final String vehicleName;
final DateTime startDate;
final DateTime endDate;
final String status;
final double totalAmount;
const DashboardRecentReservation({
required this.id,
required this.bookingRef,
required this.customerName,
required this.vehicleName,
required this.startDate,
required this.endDate,
required this.status,
required this.totalAmount,
});
factory DashboardRecentReservation.fromJson(Map<String, dynamic> json) =>
DashboardRecentReservation(
id: json['id'] as String,
bookingRef: json['bookingRef'] as String? ?? '',
customerName: json['customerName'] as String? ?? 'Unknown customer',
vehicleName: json['vehicleName'] as String? ?? 'Unknown vehicle',
startDate: DateTime.tryParse(json['startDate'] as String? ?? '') ??
DateTime.now(),
endDate: DateTime.tryParse(json['endDate'] as String? ?? '') ??
DateTime.now(),
status: json['status'] as String? ?? 'UNKNOWN',
totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? 0,
);
}
class DashboardSourceBreakdown {
final String source;
final int count;
final double revenue;
const DashboardSourceBreakdown({
required this.source,
required this.count,
required this.revenue,
});
factory DashboardSourceBreakdown.fromJson(Map<String, dynamic> json) =>
DashboardSourceBreakdown(
source: json['source'] as String? ?? 'UNKNOWN',
count: (json['count'] as num?)?.toInt() ?? 0,
revenue: (json['revenue'] as num?)?.toDouble() ?? 0,
);
}
class DashboardSubscription {
final String status;
final String? planName;
final DateTime? trialEndsAt;
const DashboardSubscription({
required this.status,
required this.planName,
required this.trialEndsAt,
});
factory DashboardSubscription.fromJson(Map<String, dynamic> json) =>
DashboardSubscription(
status: json['status'] as String? ?? 'ACTIVE',
planName: json['planName'] as String?,
trialEndsAt: json['trialEndsAt'] == null
? null
: DateTime.tryParse(json['trialEndsAt'] as String),
);
}
+17 -2
View File
@@ -58,12 +58,27 @@ class CustomerListResponse {
required this.pageSize,
});
factory CustomerListResponse.fromApi(dynamic json) {
if (json is List<dynamic>) {
final customers = json
.map((e) => Customer.fromJson(e as Map<String, dynamic>))
.toList();
return CustomerListResponse(
data: customers,
total: customers.length,
page: 1,
pageSize: customers.length,
);
}
return CustomerListResponse.fromJson(json as Map<String, dynamic>);
}
factory CustomerListResponse.fromJson(Map<String, dynamic> json) =>
CustomerListResponse(
data: (json['data'] as List<dynamic>)
data: ((json['data'] ?? const <dynamic>[]) as List<dynamic>)
.map((e) => Customer.fromJson(e as Map<String, dynamic>))
.toList(),
total: json['total'] as int? ?? 0,
total: json['total'] as int? ?? ((json['data'] as List<dynamic>?)?.length ?? 0),
page: json['page'] as int? ?? 1,
pageSize: json['pageSize'] as int? ?? 20,
);
+17 -2
View File
@@ -131,12 +131,27 @@ class ReservationListResponse {
required this.pageSize,
});
factory ReservationListResponse.fromApi(dynamic json) {
if (json is List<dynamic>) {
final reservations = json
.map((e) => Reservation.fromJson(e as Map<String, dynamic>))
.toList();
return ReservationListResponse(
data: reservations,
total: reservations.length,
page: 1,
pageSize: reservations.length,
);
}
return ReservationListResponse.fromJson(json as Map<String, dynamic>);
}
factory ReservationListResponse.fromJson(Map<String, dynamic> json) =>
ReservationListResponse(
data: (json['data'] as List<dynamic>)
data: ((json['data'] ?? const <dynamic>[]) as List<dynamic>)
.map((e) => Reservation.fromJson(e as Map<String, dynamic>))
.toList(),
total: json['total'] as int? ?? 0,
total: json['total'] as int? ?? ((json['data'] as List<dynamic>?)?.length ?? 0),
page: json['page'] as int? ?? 1,
pageSize: json['pageSize'] as int? ?? 20,
);
+17 -2
View File
@@ -72,12 +72,27 @@ class VehicleListResponse {
required this.pageSize,
});
factory VehicleListResponse.fromApi(dynamic json) {
if (json is List<dynamic>) {
final vehicles = json
.map((e) => Vehicle.fromJson(e as Map<String, dynamic>))
.toList();
return VehicleListResponse(
data: vehicles,
total: vehicles.length,
page: 1,
pageSize: vehicles.length,
);
}
return VehicleListResponse.fromJson(json as Map<String, dynamic>);
}
factory VehicleListResponse.fromJson(Map<String, dynamic> json) =>
VehicleListResponse(
data: (json['data'] as List<dynamic>)
data: ((json['data'] ?? const <dynamic>[]) as List<dynamic>)
.map((e) => Vehicle.fromJson(e as Map<String, dynamic>))
.toList(),
total: json['total'] as int? ?? 0,
total: json['total'] as int? ?? ((json['data'] as List<dynamic>?)?.length ?? 0),
page: json['page'] as int? ?? 1,
pageSize: json['pageSize'] as int? ?? 20,
);
+31 -4
View File
@@ -31,7 +31,6 @@ import '../../features/client/screens/booking_screen.dart';
import '../../features/client/screens/booking_confirmation_screen.dart';
import '../../features/client/screens/my_bookings_screen.dart';
import '../../features/client/models/booking_result.dart';
import '../../features/agency_webview/screens/agency_dashboard_webview_screen.dart';
final routerProvider = Provider<GoRouter>((ref) {
final router = GoRouter(
@@ -43,6 +42,7 @@ final routerProvider = Provider<GoRouter>((ref) {
final isSplash = loc == '/splash';
final isLanding = loc == '/landing';
final isLogin = loc.startsWith('/login');
final isAgency = loc == '/agency';
if (status == AuthStatus.unknown) {
return isSplash ? null : '/splash';
@@ -57,12 +57,20 @@ final routerProvider = Provider<GoRouter>((ref) {
return '/landing';
}
if (isAgency) {
if (status == AuthStatus.authenticated) {
return auth.userType == AppConstants.userTypeEmployee
? '/dashboard'
: '/client';
}
return '/login/employee';
}
if (status == AuthStatus.unauthenticated) {
if (isLanding ||
isLogin ||
loc.startsWith('/client') ||
loc == '/role' ||
loc == '/agency') {
loc == '/role') {
return null;
}
return '/landing';
@@ -90,6 +98,10 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/dashboard/vehicles',
builder: (context, _) => const VehiclesScreen(),
),
GoRoute(
path: '/dashboard/fleet',
builder: (context, _) => const VehiclesScreen(),
),
GoRoute(
path: '/dashboard/reservations',
builder: (context, _) => const ReservationsScreen(),
@@ -103,6 +115,11 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (_, state) =>
VehicleDetailScreen(id: state.pathParameters['id']!),
),
GoRoute(
path: '/dashboard/fleet/:id',
builder: (_, state) =>
VehicleDetailScreen(id: state.pathParameters['id']!),
),
GoRoute(
path: '/dashboard/reservations/:id',
builder: (_, state) =>
@@ -178,7 +195,7 @@ final routerProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: '/agency',
builder: (context, _) => const AgencyDashboardWebViewScreen(),
builder: (context, _) => const EmployeeLoginScreen(),
),
GoRoute(
path: '/dashboard/reservations/new',
@@ -188,12 +205,22 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/dashboard/vehicles/new',
builder: (context, _) => const VehicleFormScreen(),
),
GoRoute(
path: '/dashboard/fleet/new',
builder: (context, _) => const VehicleFormScreen(),
),
GoRoute(
path: '/dashboard/vehicles/:id/edit',
builder: (_, state) => VehicleFormScreen(
vehicle: state.extra as dynamic,
),
),
GoRoute(
path: '/dashboard/fleet/:id/edit',
builder: (_, state) => VehicleFormScreen(
vehicle: state.extra as dynamic,
),
),
GoRoute(
path: '/dashboard/customers/new',
builder: (context, _) => const CustomerFormScreen(),
+1 -1
View File
@@ -15,7 +15,7 @@ class CustomerService {
'pageSize': pageSize,
if (search != null && search.isNotEmpty) 'search': search,
});
return CustomerListResponse.fromJson(res.data as Map<String, dynamic>);
return CustomerListResponse.fromApi(res.data);
}
Future<Customer> getCustomer(String id) async {
+1 -1
View File
@@ -52,7 +52,7 @@ class ReservationService {
if (search != null && search.isNotEmpty) 'search': search,
'customerId': ?customerId,
});
return ReservationListResponse.fromJson(res.data as Map<String, dynamic>);
return ReservationListResponse.fromApi(res.data);
}
Future<Reservation> getReservation(String id) async {
+6 -7
View File
@@ -71,15 +71,14 @@ class VehicleService {
int page = 1,
int pageSize = 20,
String? status,
String? search,
}) async {
final res = await _client.get('/vehicles', params: {
'page': page,
'pageSize': pageSize,
final params = <String, dynamic>{
if (page != 1) 'page': page,
if (pageSize != 20) 'pageSize': pageSize,
'status': ?status,
if (search != null && search.isNotEmpty) 'search': search,
});
return VehicleListResponse.fromJson(res.data as Map<String, dynamic>);
};
final res = await _client.get('/vehicles', params: params);
return VehicleListResponse.fromApi(res.data);
}
Future<Vehicle> getVehicle(String id) async {
@@ -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}'),
),
),
),
);
},
),
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"@@locale": "ar",
"appName": "RentalDriveGo",
"appName": "KriTomobil",
"tagline": "أسهل طريقة للعثور\nوحجز سيارتك المثالية",
"findYourCar": "ابحث عن سيارتك",
"companySignIn": "وكالة السيارات",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"@@locale": "en",
"appName": "RentalDriveGo",
"appName": "KriTomobil",
"tagline": "The easiest way to find\nand reserve your perfect car",
"findYourCar": "Find your car",
"companySignIn": "Space Agency",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"@@locale": "fr",
"appName": "RentalDriveGo",
"appName": "KriTomobil",
"tagline": "La façon la plus simple de trouver\net réserver la voiture parfaite",
"findYourCar": "Trouver ma voiture",
"companySignIn": "Espace Agence",
+1 -1
View File
@@ -103,7 +103,7 @@ abstract class AppLocalizations {
/// No description provided for @appName.
///
/// In en, this message translates to:
/// **'RentalDriveGo'**
/// **'KriTomobil'**
String get appName;
/// No description provided for @tagline.
+1 -1
View File
@@ -9,7 +9,7 @@ class AppLocalizationsAr extends AppLocalizations {
AppLocalizationsAr([String locale = 'ar']) : super(locale);
@override
String get appName => 'RentalDriveGo';
String get appName => 'KriTomobil';
@override
String get tagline => 'أسهل طريقة للعثور\nوحجز سيارتك المثالية';
+1 -1
View File
@@ -9,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appName => 'RentalDriveGo';
String get appName => 'KriTomobil';
@override
String get tagline => 'The easiest way to find\nand reserve your perfect car';
+1 -1
View File
@@ -9,7 +9,7 @@ class AppLocalizationsFr extends AppLocalizations {
AppLocalizationsFr([String locale = 'fr']) : super(locale);
@override
String get appName => 'RentalDriveGo';
String get appName => 'KriTomobil';
@override
String get tagline =>
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class AppBackButton extends StatelessWidget {
final String fallbackRoute;
final Color? color;
const AppBackButton({
super.key,
required this.fallbackRoute,
this.color,
});
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(Icons.arrow_back_ios_new_rounded, color: color),
onPressed: () {
if (context.canPop()) {
context.pop();
return;
}
context.go(fallbackRoute);
},
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
);
}
}
+3 -1
View File
@@ -8,6 +8,8 @@ class ErrorView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
@@ -19,7 +21,7 @@ class ErrorView extends StatelessWidget {
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF6B7280)),
style: TextStyle(color: cs.onSurfaceVariant),
),
if (onRetry != null) ...[
const SizedBox(height: 16),
+6 -3
View File
@@ -9,9 +9,12 @@ class LoadingList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Shimmer.fromColors(
baseColor: const Color(0xFFE5E7EB),
highlightColor: const Color(0xFFF9FAFB),
baseColor: isDark ? const Color(0xFF2F3145) : const Color(0xFFE5E7EB),
highlightColor:
isDark ? const Color(0xFF3A3C54) : const Color(0xFFF9FAFB),
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: count,
@@ -19,7 +22,7 @@ class LoadingList extends StatelessWidget {
itemBuilder: (_, _) => Container(
height: itemHeight,
decoration: BoxDecoration(
color: Colors.white,
color: isDark ? const Color(0xFF28293A) : Colors.white,
borderRadius: BorderRadius.circular(12),
),
),
+22 -10
View File
@@ -12,6 +12,8 @@ class ReservationCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final fmt = DateFormat('MMM d, yyyy');
final cs = Theme.of(context).colorScheme;
return Card(
child: InkWell(
onTap: onTap,
@@ -39,34 +41,44 @@ class ReservationCard extends StatelessWidget {
const SizedBox(height: 4),
Text(
reservation.customer!.fullName,
style: const TextStyle(
fontSize: 13, color: Color(0xFF6B7280)),
style: TextStyle(
fontSize: 13,
color: cs.onSurfaceVariant,
),
),
],
const SizedBox(height: 10),
Row(
children: [
const Icon(Icons.calendar_today,
size: 14, color: Color(0xFF9CA3AF)),
Icon(
Icons.calendar_today,
size: 14,
color: cs.onSurfaceVariant.withValues(alpha: 0.8),
),
const SizedBox(width: 4),
Text(
'${fmt.format(reservation.startDate)}${fmt.format(reservation.endDate)}',
style: const TextStyle(
fontSize: 13, color: Color(0xFF6B7280)),
style: TextStyle(
fontSize: 13,
color: cs.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.attach_money,
size: 14, color: Color(0xFF9CA3AF)),
Icon(
Icons.attach_money,
size: 14,
color: cs.onSurfaceVariant.withValues(alpha: 0.8),
),
const SizedBox(width: 4),
Text(
'\$${(reservation.totalAmount / 100).toStringAsFixed(2)}',
style: const TextStyle(
style: TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFF111928),
color: cs.onSurface,
),
),
const SizedBox(width: 8),
+44 -6
View File
@@ -6,6 +6,8 @@ class StatCard extends StatelessWidget {
final IconData icon;
final Color color;
final String? subtitle;
final double? change;
final String? footerLabel;
const StatCard({
super.key,
@@ -14,10 +16,14 @@ class StatCard extends StatelessWidget {
required this.icon,
required this.color,
this.subtitle,
this.change,
this.footerLabel,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -40,30 +46,62 @@ class StatCard extends StatelessWidget {
const SizedBox(height: 12),
Text(
value,
style: const TextStyle(
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Color(0xFF111928),
color: cs.onSurface,
),
),
const SizedBox(height: 4),
Text(
title,
style: const TextStyle(
style: TextStyle(
fontSize: 13,
color: Color(0xFF6B7280),
color: cs.onSurfaceVariant,
),
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: const TextStyle(
style: TextStyle(
fontSize: 11,
color: Color(0xFF9CA3AF),
color: cs.onSurfaceVariant.withValues(alpha: 0.85),
),
),
],
if (change != null) ...[
const SizedBox(height: 6),
Row(
children: [
Text(
'${change! >= 0 ? '+' : ''}${change!.toStringAsFixed(1)}%',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: change! > 0
? const Color(0xFF0E9F6E)
: change! < 0
? const Color(0xFFE02424)
: cs.onSurfaceVariant,
),
),
if (footerLabel != null) ...[
const SizedBox(width: 6),
Expanded(
child: Text(
footerLabel!,
style: TextStyle(
fontSize: 11,
color: cs.onSurfaceVariant.withValues(alpha: 0.85),
),
overflow: TextOverflow.ellipsis,
),
),
],
],
),
],
],
),
),
+9 -5
View File
@@ -12,6 +12,8 @@ class VehicleCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
@@ -44,9 +46,9 @@ class VehicleCard extends StatelessWidget {
const SizedBox(height: 4),
Text(
vehicle.licensePlate,
style: const TextStyle(
style: TextStyle(
fontSize: 13,
color: Color(0xFF6B7280),
color: cs.onSurfaceVariant,
),
),
const SizedBox(height: 8),
@@ -66,13 +68,15 @@ class VehicleCard extends StatelessWidget {
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFF3F4F6),
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
vehicle.category,
style: const TextStyle(
fontSize: 11, color: Color(0xFF6B7280)),
style: TextStyle(
fontSize: 11,
color: cs.onSurfaceVariant,
),
),
),
],