fix phone dashboard
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"@@locale": "ar",
|
||||
"appName": "RentalDriveGo",
|
||||
"appName": "KriTomobil",
|
||||
"tagline": "أسهل طريقة للعثور\nوحجز سيارتك المثالية",
|
||||
"findYourCar": "ابحث عن سيارتك",
|
||||
"companySignIn": "وكالة السيارات",
|
||||
|
||||
+1
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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وحجز سيارتك المثالية';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user