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.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
<application
|
<application
|
||||||
android:label="car_rental_app"
|
android:label="KriTomobil"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/launcher_icon">
|
android:icon="@mipmap/launcher_icon">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -1,2 +1,12 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
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
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
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 {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
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
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Car Rental App</string>
|
<string>KriTomobil</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>car_rental_app</string>
|
<string>KriTomobil</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ class CarRentalApp extends ConsumerWidget {
|
|||||||
final themeMode = ref.watch(themeProvider);
|
final themeMode = ref.watch(themeProvider);
|
||||||
|
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'RentalDriveGo',
|
title: 'KriTomobil',
|
||||||
theme: AppTheme.light,
|
theme: AppTheme.light,
|
||||||
darkTheme: AppTheme.dark,
|
darkTheme: AppTheme.dark,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class AppConstants {
|
|||||||
|
|
||||||
// Production
|
// Production
|
||||||
static const String productionApiUrl = 'https://api.rentaldrivego.ma/api/v1';
|
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
|
// Development
|
||||||
static const String devAndroidApiUrl = 'http://10.0.2.2:4000/api/v1';
|
static const String devAndroidApiUrl = 'http://10.0.2.2:4000/api/v1';
|
||||||
@@ -18,14 +18,12 @@ class AppConstants {
|
|||||||
|
|
||||||
static String get baseUrl {
|
static String get baseUrl {
|
||||||
if (apiBaseUrlOverride.isNotEmpty) return apiBaseUrlOverride;
|
if (apiBaseUrlOverride.isNotEmpty) return apiBaseUrlOverride;
|
||||||
if (Platform.isAndroid) return devAndroidApiUrl;
|
return productionApiUrl;
|
||||||
return devLocalApiUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static String get dashboardBaseUrl {
|
static String get dashboardBaseUrl {
|
||||||
if (dashboardUrlOverride.isNotEmpty) return dashboardUrlOverride;
|
if (dashboardUrlOverride.isNotEmpty) return dashboardUrlOverride;
|
||||||
if (Platform.isAndroid) return devAndroidDashboardUrl;
|
return productionDashboardUrl;
|
||||||
return devLocalDashboardUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static String resolveMediaUrl(String url) {
|
static String resolveMediaUrl(String url) {
|
||||||
|
|||||||
+171
-32
@@ -1,40 +1,179 @@
|
|||||||
class DashboardMetrics {
|
class DashboardMetrics {
|
||||||
final int totalReservations;
|
final DashboardKpis kpis;
|
||||||
final int activeReservations;
|
final List<DashboardRecentReservation> recentReservations;
|
||||||
final double totalRevenue;
|
final List<DashboardSourceBreakdown> sourceBreakdown;
|
||||||
final int totalVehicles;
|
final DashboardSubscription? subscription;
|
||||||
final int availableVehicles;
|
|
||||||
final int totalCustomers;
|
|
||||||
final double occupancyRate;
|
|
||||||
|
|
||||||
const DashboardMetrics({
|
const DashboardMetrics({
|
||||||
required this.totalReservations,
|
required this.kpis,
|
||||||
required this.activeReservations,
|
required this.recentReservations,
|
||||||
required this.totalRevenue,
|
required this.sourceBreakdown,
|
||||||
required this.totalVehicles,
|
required this.subscription,
|
||||||
required this.availableVehicles,
|
|
||||||
required this.totalCustomers,
|
|
||||||
required this.occupancyRate,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
factory DashboardMetrics.fromJson(Map<String, dynamic> json) =>
|
factory DashboardMetrics.fromJson(Map<String, dynamic> json) {
|
||||||
DashboardMetrics(
|
final kpiJson = json['kpis'] as Map<String, dynamic>?;
|
||||||
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.empty() => const DashboardMetrics(
|
return DashboardMetrics(
|
||||||
totalReservations: 0,
|
kpis: DashboardKpis.fromJson(kpiJson ?? json),
|
||||||
activeReservations: 0,
|
recentReservations: (json['recentReservations'] as List<dynamic>?)
|
||||||
totalRevenue: 0,
|
?.map(
|
||||||
totalVehicles: 0,
|
(e) => DashboardRecentReservation.fromJson(
|
||||||
availableVehicles: 0,
|
e as Map<String, dynamic>,
|
||||||
totalCustomers: 0,
|
),
|
||||||
occupancyRate: 0,
|
)
|
||||||
|
.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,
|
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) =>
|
factory CustomerListResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
CustomerListResponse(
|
CustomerListResponse(
|
||||||
data: (json['data'] as List<dynamic>)
|
data: ((json['data'] ?? const <dynamic>[]) as List<dynamic>)
|
||||||
.map((e) => Customer.fromJson(e as Map<String, dynamic>))
|
.map((e) => Customer.fromJson(e as Map<String, dynamic>))
|
||||||
.toList(),
|
.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,
|
page: json['page'] as int? ?? 1,
|
||||||
pageSize: json['pageSize'] as int? ?? 20,
|
pageSize: json['pageSize'] as int? ?? 20,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -131,12 +131,27 @@ class ReservationListResponse {
|
|||||||
required this.pageSize,
|
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) =>
|
factory ReservationListResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
ReservationListResponse(
|
ReservationListResponse(
|
||||||
data: (json['data'] as List<dynamic>)
|
data: ((json['data'] ?? const <dynamic>[]) as List<dynamic>)
|
||||||
.map((e) => Reservation.fromJson(e as Map<String, dynamic>))
|
.map((e) => Reservation.fromJson(e as Map<String, dynamic>))
|
||||||
.toList(),
|
.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,
|
page: json['page'] as int? ?? 1,
|
||||||
pageSize: json['pageSize'] as int? ?? 20,
|
pageSize: json['pageSize'] as int? ?? 20,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,12 +72,27 @@ class VehicleListResponse {
|
|||||||
required this.pageSize,
|
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) =>
|
factory VehicleListResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
VehicleListResponse(
|
VehicleListResponse(
|
||||||
data: (json['data'] as List<dynamic>)
|
data: ((json['data'] ?? const <dynamic>[]) as List<dynamic>)
|
||||||
.map((e) => Vehicle.fromJson(e as Map<String, dynamic>))
|
.map((e) => Vehicle.fromJson(e as Map<String, dynamic>))
|
||||||
.toList(),
|
.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,
|
page: json['page'] as int? ?? 1,
|
||||||
pageSize: json['pageSize'] as int? ?? 20,
|
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/booking_confirmation_screen.dart';
|
||||||
import '../../features/client/screens/my_bookings_screen.dart';
|
import '../../features/client/screens/my_bookings_screen.dart';
|
||||||
import '../../features/client/models/booking_result.dart';
|
import '../../features/client/models/booking_result.dart';
|
||||||
import '../../features/agency_webview/screens/agency_dashboard_webview_screen.dart';
|
|
||||||
|
|
||||||
final routerProvider = Provider<GoRouter>((ref) {
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
@@ -43,6 +42,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
final isSplash = loc == '/splash';
|
final isSplash = loc == '/splash';
|
||||||
final isLanding = loc == '/landing';
|
final isLanding = loc == '/landing';
|
||||||
final isLogin = loc.startsWith('/login');
|
final isLogin = loc.startsWith('/login');
|
||||||
|
final isAgency = loc == '/agency';
|
||||||
|
|
||||||
if (status == AuthStatus.unknown) {
|
if (status == AuthStatus.unknown) {
|
||||||
return isSplash ? null : '/splash';
|
return isSplash ? null : '/splash';
|
||||||
@@ -57,12 +57,20 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return '/landing';
|
return '/landing';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAgency) {
|
||||||
|
if (status == AuthStatus.authenticated) {
|
||||||
|
return auth.userType == AppConstants.userTypeEmployee
|
||||||
|
? '/dashboard'
|
||||||
|
: '/client';
|
||||||
|
}
|
||||||
|
return '/login/employee';
|
||||||
|
}
|
||||||
|
|
||||||
if (status == AuthStatus.unauthenticated) {
|
if (status == AuthStatus.unauthenticated) {
|
||||||
if (isLanding ||
|
if (isLanding ||
|
||||||
isLogin ||
|
isLogin ||
|
||||||
loc.startsWith('/client') ||
|
loc.startsWith('/client') ||
|
||||||
loc == '/role' ||
|
loc == '/role') {
|
||||||
loc == '/agency') {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return '/landing';
|
return '/landing';
|
||||||
@@ -90,6 +98,10 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: '/dashboard/vehicles',
|
path: '/dashboard/vehicles',
|
||||||
builder: (context, _) => const VehiclesScreen(),
|
builder: (context, _) => const VehiclesScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/dashboard/fleet',
|
||||||
|
builder: (context, _) => const VehiclesScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/dashboard/reservations',
|
path: '/dashboard/reservations',
|
||||||
builder: (context, _) => const ReservationsScreen(),
|
builder: (context, _) => const ReservationsScreen(),
|
||||||
@@ -103,6 +115,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
builder: (_, state) =>
|
builder: (_, state) =>
|
||||||
VehicleDetailScreen(id: state.pathParameters['id']!),
|
VehicleDetailScreen(id: state.pathParameters['id']!),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/dashboard/fleet/:id',
|
||||||
|
builder: (_, state) =>
|
||||||
|
VehicleDetailScreen(id: state.pathParameters['id']!),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/dashboard/reservations/:id',
|
path: '/dashboard/reservations/:id',
|
||||||
builder: (_, state) =>
|
builder: (_, state) =>
|
||||||
@@ -178,7 +195,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/agency',
|
path: '/agency',
|
||||||
builder: (context, _) => const AgencyDashboardWebViewScreen(),
|
builder: (context, _) => const EmployeeLoginScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/dashboard/reservations/new',
|
path: '/dashboard/reservations/new',
|
||||||
@@ -188,12 +205,22 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: '/dashboard/vehicles/new',
|
path: '/dashboard/vehicles/new',
|
||||||
builder: (context, _) => const VehicleFormScreen(),
|
builder: (context, _) => const VehicleFormScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/dashboard/fleet/new',
|
||||||
|
builder: (context, _) => const VehicleFormScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/dashboard/vehicles/:id/edit',
|
path: '/dashboard/vehicles/:id/edit',
|
||||||
builder: (_, state) => VehicleFormScreen(
|
builder: (_, state) => VehicleFormScreen(
|
||||||
vehicle: state.extra as dynamic,
|
vehicle: state.extra as dynamic,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/dashboard/fleet/:id/edit',
|
||||||
|
builder: (_, state) => VehicleFormScreen(
|
||||||
|
vehicle: state.extra as dynamic,
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/dashboard/customers/new',
|
path: '/dashboard/customers/new',
|
||||||
builder: (context, _) => const CustomerFormScreen(),
|
builder: (context, _) => const CustomerFormScreen(),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class CustomerService {
|
|||||||
'pageSize': pageSize,
|
'pageSize': pageSize,
|
||||||
if (search != null && search.isNotEmpty) 'search': search,
|
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 {
|
Future<Customer> getCustomer(String id) async {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class ReservationService {
|
|||||||
if (search != null && search.isNotEmpty) 'search': search,
|
if (search != null && search.isNotEmpty) 'search': search,
|
||||||
'customerId': ?customerId,
|
'customerId': ?customerId,
|
||||||
});
|
});
|
||||||
return ReservationListResponse.fromJson(res.data as Map<String, dynamic>);
|
return ReservationListResponse.fromApi(res.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Reservation> getReservation(String id) async {
|
Future<Reservation> getReservation(String id) async {
|
||||||
|
|||||||
@@ -71,15 +71,14 @@ class VehicleService {
|
|||||||
int page = 1,
|
int page = 1,
|
||||||
int pageSize = 20,
|
int pageSize = 20,
|
||||||
String? status,
|
String? status,
|
||||||
String? search,
|
|
||||||
}) async {
|
}) async {
|
||||||
final res = await _client.get('/vehicles', params: {
|
final params = <String, dynamic>{
|
||||||
'page': page,
|
if (page != 1) 'page': page,
|
||||||
'pageSize': pageSize,
|
if (pageSize != 20) 'pageSize': pageSize,
|
||||||
'status': ?status,
|
'status': ?status,
|
||||||
if (search != null && search.isNotEmpty) 'search': search,
|
};
|
||||||
});
|
final res = await _client.get('/vehicles', params: params);
|
||||||
return VehicleListResponse.fromJson(res.data as Map<String, dynamic>);
|
return VehicleListResponse.fromApi(res.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Vehicle> getVehicle(String id) async {
|
Future<Vehicle> getVehicle(String id) async {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
|
import '../../../widgets/app_back_button.dart';
|
||||||
|
|
||||||
class AgencyDashboardWebViewScreen extends StatefulWidget {
|
class AgencyDashboardWebViewScreen extends StatefulWidget {
|
||||||
const AgencyDashboardWebViewScreen({super.key});
|
const AgencyDashboardWebViewScreen({super.key});
|
||||||
@@ -53,7 +54,7 @@ class _AgencyDashboardWebViewScreenState
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Agency Dashboard'),
|
title: const Text('Agency Dashboard'),
|
||||||
leading: BackButton(onPressed: () => context.go('/landing')),
|
leading: const AppBackButton(fallbackRoute: '/landing'),
|
||||||
actions: [
|
actions: [
|
||||||
if (!_isLoading)
|
if (!_isLoading)
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/providers/auth_provider.dart';
|
import '../../../core/providers/auth_provider.dart';
|
||||||
|
|
||||||
class EmployeeLoginScreen extends ConsumerStatefulWidget {
|
class EmployeeLoginScreen extends ConsumerStatefulWidget {
|
||||||
@@ -37,6 +38,9 @@ class _EmployeeLoginScreenState extends ConsumerState<EmployeeLoginScreen> {
|
|||||||
_emailController.text.trim(),
|
_emailController.text.trim(),
|
||||||
_passwordController.text,
|
_passwordController.text,
|
||||||
);
|
);
|
||||||
|
if (mounted) {
|
||||||
|
context.go('/dashboard');
|
||||||
|
}
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = _messageForDioError(e);
|
_error = _messageForDioError(e);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class LandingScreen extends StatelessWidget {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () => context.go('/client'),
|
onPressed: () => context.go('/client'),
|
||||||
|
//onPressed: () => context.push('/client'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFFFF6B00),
|
backgroundColor: const Color(0xFFFF6B00),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
@@ -86,7 +87,7 @@ class LandingScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.go('/agency'),
|
onPressed: () => context.push('/login/employee'),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: Colors.white.withValues(alpha: 0.5),
|
foregroundColor: Colors.white.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../widgets/app_back_button.dart';
|
||||||
|
|
||||||
class RoleScreen extends StatelessWidget {
|
class RoleScreen extends StatelessWidget {
|
||||||
const RoleScreen({super.key});
|
const RoleScreen({super.key});
|
||||||
@@ -13,7 +14,8 @@ class RoleScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 40),
|
const AppBackButton(fallbackRoute: '/landing'),
|
||||||
|
const SizedBox(height: 24),
|
||||||
Container(
|
Container(
|
||||||
width: 60,
|
width: 60,
|
||||||
height: 60,
|
height: 60,
|
||||||
@@ -53,7 +55,7 @@ class RoleScreen extends StatelessWidget {
|
|||||||
title: 'Browse & Reserve',
|
title: 'Browse & Reserve',
|
||||||
subtitle: 'Find and book a vehicle',
|
subtitle: 'Find and book a vehicle',
|
||||||
color: const Color(0xFF0E9F6E),
|
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 SizedBox(height: 20),
|
||||||
const Text(
|
const Text(
|
||||||
'RentalDriveGo',
|
'KriTomobil',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../widgets/app_back_button.dart';
|
||||||
import '../models/booking_result.dart';
|
import '../models/booking_result.dart';
|
||||||
|
|
||||||
const _orange = Color(0xFFFF6B00);
|
const _orange = Color(0xFFFF6B00);
|
||||||
@@ -18,6 +19,7 @@ class BookingConfirmationScreen extends StatelessWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
leading: const AppBackButton(fallbackRoute: '/client'),
|
||||||
title: const Text('Booking Confirmed'),
|
title: const Text('Booking Confirmed'),
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../../core/models/marketplace.dart';
|
import '../../../core/models/marketplace.dart';
|
||||||
|
import '../../../widgets/app_back_button.dart';
|
||||||
import '../models/booking_result.dart';
|
import '../models/booking_result.dart';
|
||||||
import '../providers/client_providers.dart';
|
import '../providers/client_providers.dart';
|
||||||
|
|
||||||
@@ -130,7 +131,10 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Complete Booking')),
|
appBar: AppBar(
|
||||||
|
leading: const AppBackButton(fallbackRoute: '/client'),
|
||||||
|
title: const Text('Complete Booking'),
|
||||||
|
),
|
||||||
body: Form(
|
body: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:intl/intl.dart';
|
|||||||
import '../providers/client_providers.dart';
|
import '../providers/client_providers.dart';
|
||||||
import '../../../core/models/marketplace.dart';
|
import '../../../core/models/marketplace.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
import '../../../widgets/app_back_button.dart';
|
||||||
import '../../../widgets/preferences_bar.dart';
|
import '../../../widgets/preferences_bar.dart';
|
||||||
import 'client_shell.dart';
|
import 'client_shell.dart';
|
||||||
import 'widgets/marketplace_vehicle_card.dart';
|
import 'widgets/marketplace_vehicle_card.dart';
|
||||||
@@ -233,15 +234,21 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
|||||||
padding: const EdgeInsets.fromLTRB(20, 16, 12, 0),
|
padding: const EdgeInsets.fromLTRB(20, 16, 12, 0),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
AppBackButton(
|
||||||
l10n.findCarToRent,
|
fallbackRoute: '/landing',
|
||||||
style: TextStyle(
|
color: cs.onSurface,
|
||||||
color: cs.onSurface,
|
),
|
||||||
fontSize: 20,
|
const SizedBox(width: 4),
|
||||||
fontWeight: FontWeight.bold,
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.findCarToRent,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
|
||||||
const PreferencesBar(),
|
const PreferencesBar(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../../core/models/marketplace.dart';
|
import '../../../core/models/marketplace.dart';
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
|
import '../../../widgets/app_back_button.dart';
|
||||||
import '../providers/client_providers.dart';
|
import '../providers/client_providers.dart';
|
||||||
|
|
||||||
class ClientVehicleDetailScreen extends ConsumerWidget {
|
class ClientVehicleDetailScreen extends ConsumerWidget {
|
||||||
@@ -48,6 +49,7 @@ class _DetailView extends StatelessWidget {
|
|||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 280,
|
expandedHeight: 280,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
|
leading: const AppBackButton(fallbackRoute: '/client'),
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
background: vehicle.primaryPhoto != null
|
background: vehicle.primaryPhoto != null
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/providers/auth_provider.dart';
|
import '../../../core/providers/auth_provider.dart';
|
||||||
|
import '../../../widgets/app_back_button.dart';
|
||||||
import 'client_shell.dart';
|
import 'client_shell.dart';
|
||||||
|
|
||||||
class MyBookingsScreen extends ConsumerWidget {
|
class MyBookingsScreen extends ConsumerWidget {
|
||||||
@@ -13,7 +14,10 @@ class MyBookingsScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
if (auth.status != AuthStatus.authenticated) {
|
if (auth.status != AuthStatus.authenticated) {
|
||||||
return ClientShell(
|
return ClientShell(
|
||||||
appBar: AppBar(title: const Text('My Bookings')),
|
appBar: AppBar(
|
||||||
|
leading: const AppBackButton(fallbackRoute: '/client'),
|
||||||
|
title: const Text('My Bookings'),
|
||||||
|
),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
@@ -53,7 +57,10 @@ class MyBookingsScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ClientShell(
|
return ClientShell(
|
||||||
appBar: AppBar(title: const Text('My Bookings')),
|
appBar: AppBar(
|
||||||
|
leading: const AppBackButton(fallbackRoute: '/client'),
|
||||||
|
title: const Text('My Bookings'),
|
||||||
|
),
|
||||||
body: const Center(
|
body: const Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|||||||
@@ -34,24 +34,35 @@ final dashboardMetricsProvider = FutureProvider<DashboardMetrics>((ref) async {
|
|||||||
return ref.read(analyticsServiceProvider).getDashboard();
|
return ref.read(analyticsServiceProvider).getDashboard();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
typedef ReportQuery = ({
|
||||||
|
String startDate,
|
||||||
|
String endDate,
|
||||||
|
String? status,
|
||||||
|
});
|
||||||
|
|
||||||
final reportProvider =
|
final reportProvider =
|
||||||
FutureProvider.family<ReportSummary, Map<String, String>>((ref, params) async {
|
FutureProvider.family<ReportSummary, ReportQuery>((ref, params) async {
|
||||||
return ref.read(analyticsServiceProvider).getReport(
|
return ref.read(analyticsServiceProvider).getReport(
|
||||||
startDate: params['startDate']!,
|
startDate: params.startDate,
|
||||||
endDate: params['endDate']!,
|
endDate: params.endDate,
|
||||||
status: params['status'],
|
status: params.status,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Vehicles ─────────────────────────────────────────────────
|
// ─── 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 {
|
(ref, params) async {
|
||||||
return ref.read(vehicleServiceProvider).getVehicles(
|
return ref.read(vehicleServiceProvider).getVehicles(
|
||||||
page: params['page'] as int? ?? 1,
|
page: params.page,
|
||||||
pageSize: params['pageSize'] as int? ?? 20,
|
pageSize: params.pageSize,
|
||||||
status: params['status'] as String?,
|
status: params.status,
|
||||||
search: params['search'] as String?,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -75,16 +86,25 @@ final calendarProvider =
|
|||||||
|
|
||||||
// ─── Reservations ─────────────────────────────────────────────
|
// ─── Reservations ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
typedef ReservationQuery = ({
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
String? status,
|
||||||
|
String? source,
|
||||||
|
String? search,
|
||||||
|
String? customerId,
|
||||||
|
});
|
||||||
|
|
||||||
final reservationsProvider =
|
final reservationsProvider =
|
||||||
FutureProvider.family<ReservationListResponse, Map<String, dynamic>>(
|
FutureProvider.family<ReservationListResponse, ReservationQuery>(
|
||||||
(ref, params) async {
|
(ref, params) async {
|
||||||
return ref.read(reservationServiceProvider).getReservations(
|
return ref.read(reservationServiceProvider).getReservations(
|
||||||
page: params['page'] as int? ?? 1,
|
page: params.page,
|
||||||
pageSize: params['pageSize'] as int? ?? 20,
|
pageSize: params.pageSize,
|
||||||
status: params['status'] as String?,
|
status: params.status,
|
||||||
source: params['source'] as String?,
|
source: params.source,
|
||||||
search: params['search'] as String?,
|
search: params.search,
|
||||||
customerId: params['customerId'] as String?,
|
customerId: params.customerId,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -101,13 +121,19 @@ final inspectionsProvider =
|
|||||||
|
|
||||||
// ─── Customers ────────────────────────────────────────────────
|
// ─── Customers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
typedef CustomerQuery = ({
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
String? search,
|
||||||
|
});
|
||||||
|
|
||||||
final customersProvider =
|
final customersProvider =
|
||||||
FutureProvider.family<CustomerListResponse, Map<String, dynamic>>(
|
FutureProvider.family<CustomerListResponse, CustomerQuery>(
|
||||||
(ref, params) async {
|
(ref, params) async {
|
||||||
return ref.read(customerServiceProvider).getCustomers(
|
return ref.read(customerServiceProvider).getCustomers(
|
||||||
page: params['page'] as int? ?? 1,
|
page: params.page,
|
||||||
pageSize: params['pageSize'] as int? ?? 20,
|
pageSize: params.pageSize,
|
||||||
search: params['search'] as String?,
|
search: params.search,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,33 @@ class _ContractsScreenState extends ConsumerState<ContractsScreen> {
|
|||||||
final _searchCtrl = TextEditingController();
|
final _searchCtrl = TextEditingController();
|
||||||
String _search = '';
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchCtrl.dispose();
|
_searchCtrl.dispose();
|
||||||
@@ -23,12 +50,14 @@ class _ContractsScreenState extends ConsumerState<ContractsScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final params = {
|
final ReservationQuery params = (
|
||||||
'page': 1,
|
page: 1,
|
||||||
'pageSize': 100,
|
pageSize: 100,
|
||||||
'status': 'CONFIRMED,ACTIVE,COMPLETED,CLOSED',
|
status: null,
|
||||||
if (_search.isNotEmpty) 'search': _search,
|
source: null,
|
||||||
};
|
search: null,
|
||||||
|
customerId: null,
|
||||||
|
);
|
||||||
|
|
||||||
final asyncData = ref.watch(reservationsProvider(params));
|
final asyncData = ref.watch(reservationsProvider(params));
|
||||||
|
|
||||||
@@ -69,9 +98,7 @@ class _ContractsScreenState extends ConsumerState<ContractsScreen> {
|
|||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('Error: $e')),
|
error: (e, _) => Center(child: Text('Error: $e')),
|
||||||
data: (response) {
|
data: (response) {
|
||||||
final contracts = response.data
|
final contracts = _visibleContracts(response.data);
|
||||||
.where((r) => r.contractNumber != null)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (contracts.isEmpty) {
|
if (contracts.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
|
|||||||
@@ -17,7 +17,14 @@ class CustomerDetailScreen extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final async = ref.watch(customerDetailProvider(id));
|
final async = ref.watch(customerDetailProvider(id));
|
||||||
final reservationsAsync = ref.watch(
|
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(
|
return async.when(
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ class _CustomersScreenState extends ConsumerState<CustomersScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> get _params => {
|
CustomerQuery get _params => (
|
||||||
'page': 1,
|
page: 1,
|
||||||
'pageSize': 50,
|
pageSize: 50,
|
||||||
if (_search.isNotEmpty) 'search': _search,
|
search: _search.isNotEmpty ? _search : null,
|
||||||
};
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ class DashboardShell extends ConsumerWidget {
|
|||||||
final employee = ref.watch(authProvider).employee;
|
final employee = ref.watch(authProvider).employee;
|
||||||
|
|
||||||
int currentIndex = 0;
|
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/reservations')) currentIndex = 2;
|
||||||
if (location.startsWith('/dashboard/customers')) currentIndex = 3;
|
if (location.startsWith('/dashboard/customers')) currentIndex = 3;
|
||||||
|
|
||||||
@@ -37,15 +40,14 @@ class DashboardShell extends ConsumerWidget {
|
|||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: currentIndex,
|
selectedIndex: currentIndex,
|
||||||
onDestinationSelected: (i) {
|
onDestinationSelected: (i) {
|
||||||
switch (i) {
|
if (i == 0) {
|
||||||
case 0:
|
context.go('/dashboard');
|
||||||
context.go('/dashboard');
|
} else if (i == 1) {
|
||||||
case 1:
|
context.go('/dashboard/fleet');
|
||||||
context.go('/dashboard/vehicles');
|
} else if (i == 2) {
|
||||||
case 2:
|
context.go('/dashboard/reservations');
|
||||||
context.go('/dashboard/reservations');
|
} else if (i == 3) {
|
||||||
case 3:
|
context.go('/dashboard/customers');
|
||||||
context.go('/dashboard/customers');
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
destinations: const [
|
destinations: const [
|
||||||
@@ -57,7 +59,7 @@ class DashboardShell extends ConsumerWidget {
|
|||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.directions_car_outlined),
|
icon: Icon(Icons.directions_car_outlined),
|
||||||
selectedIcon: Icon(Icons.directions_car),
|
selectedIcon: Icon(Icons.directions_car),
|
||||||
label: 'Vehicles',
|
label: 'Fleet',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.event_note_outlined),
|
icon: Icon(Icons.event_note_outlined),
|
||||||
@@ -141,10 +143,10 @@ class _Drawer extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: Icons.directions_car_outlined,
|
icon: Icons.directions_car_outlined,
|
||||||
label: 'Vehicles',
|
label: 'Fleet',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
context.go('/dashboard/vehicles');
|
context.go('/dashboard/fleet');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
@@ -166,7 +168,7 @@ class _Drawer extends ConsumerWidget {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: Icons.public_outlined,
|
icon: Icons.public_outlined,
|
||||||
label: 'Online Requests',
|
label: 'Online Reservations',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
context.push('/dashboard/online-reservations');
|
context.push('/dashboard/online-reservations');
|
||||||
|
|||||||
@@ -1,31 +1,47 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../../../core/models/analytics.dart';
|
||||||
|
import '../../../core/models/reservation.dart';
|
||||||
import '../../../core/providers/auth_provider.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/error_view.dart';
|
||||||
import '../../../widgets/reservation_card.dart';
|
import '../../../widgets/reservation_card.dart';
|
||||||
|
import '../../../widgets/stat_card.dart';
|
||||||
|
import '../providers/dashboard_providers.dart';
|
||||||
|
import 'dashboard_shell.dart';
|
||||||
|
|
||||||
class HomeScreen extends ConsumerWidget {
|
class HomeScreen extends ConsumerWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
final employee = ref.watch(authProvider).employee;
|
final employee = ref.watch(authProvider).employee;
|
||||||
|
final canViewRevenue = employee?.role != 'AGENT';
|
||||||
final metricsAsync = ref.watch(dashboardMetricsProvider);
|
final metricsAsync = ref.watch(dashboardMetricsProvider);
|
||||||
final recentAsync = ref.watch(
|
final fallbackRecentAsync = ref.watch(
|
||||||
reservationsProvider({'page': 1, 'pageSize': 5}),
|
reservationsProvider((
|
||||||
|
page: 1,
|
||||||
|
pageSize: 5,
|
||||||
|
status: null,
|
||||||
|
source: null,
|
||||||
|
search: null,
|
||||||
|
customerId: null,
|
||||||
|
)),
|
||||||
);
|
);
|
||||||
final onlineAsync = ref.watch(
|
final onlineAsync = ref.watch(
|
||||||
reservationsProvider({
|
reservationsProvider((
|
||||||
'status': 'DRAFT',
|
page: 1,
|
||||||
'source': 'MARKETPLACE',
|
pageSize: 100,
|
||||||
'page': 1,
|
status: 'DRAFT',
|
||||||
'pageSize': 100,
|
source: 'MARKETPLACE',
|
||||||
}),
|
search: null,
|
||||||
|
customerId: null,
|
||||||
|
)),
|
||||||
);
|
);
|
||||||
final pendingOnline = onlineAsync.valueOrNull?.data.length ?? 0;
|
final pendingOnline = onlineAsync.valueOrNull?.data.length ?? 0;
|
||||||
final unreadCount = ref.watch(unreadCountProvider).valueOrNull ?? 0;
|
final unreadCount = ref.watch(unreadCountProvider).valueOrNull ?? 0;
|
||||||
@@ -35,13 +51,13 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
title: Column(
|
title: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
const Text(
|
||||||
'Hello, ${employee?.firstName ?? 'there'}',
|
'Dashboard',
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
DateFormat('EEEE, MMMM d').format(DateTime.now()),
|
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(dashboardMetricsProvider);
|
||||||
ref.invalidate(reservationsProvider);
|
ref.invalidate(reservationsProvider);
|
||||||
},
|
},
|
||||||
child: ListView(
|
child: metricsAsync.when(
|
||||||
padding: const EdgeInsets.all(16),
|
loading: () => ListView(
|
||||||
children: [
|
padding: const EdgeInsets.all(16),
|
||||||
metricsAsync.when(
|
children: const [
|
||||||
loading: () => const SizedBox(
|
SizedBox(height: 240, child: Center(child: CircularProgressIndicator())),
|
||||||
height: 200,
|
],
|
||||||
child: Center(child: CircularProgressIndicator()),
|
),
|
||||||
),
|
error: (e, _) => ListView(
|
||||||
error: (e, _) => ErrorView(
|
padding: const EdgeInsets.all(16),
|
||||||
message: 'Could not load metrics',
|
children: [
|
||||||
|
ErrorView(
|
||||||
|
message: 'Could not load dashboard',
|
||||||
onRetry: () => ref.invalidate(dashboardMetricsProvider),
|
onRetry: () => ref.invalidate(dashboardMetricsProvider),
|
||||||
),
|
),
|
||||||
data: (metrics) => Column(
|
],
|
||||||
children: [
|
),
|
||||||
Row(
|
data: (metrics) => ListView(
|
||||||
children: [
|
padding: const EdgeInsets.all(16),
|
||||||
Expanded(
|
children: [
|
||||||
child: StatCard(
|
if (metrics.subscription != null &&
|
||||||
title: 'Total Vehicles',
|
metrics.subscription!.status != 'ACTIVE')
|
||||||
value: '${metrics.totalVehicles}',
|
Padding(
|
||||||
icon: Icons.directions_car,
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
color: const Color(0xFF1A56DB),
|
child: _SubscriptionBanner(
|
||||||
subtitle: '${metrics.availableVehicles} available',
|
subscription: metrics.subscription!,
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: StatCard(
|
|
||||||
title: 'Active Rentals',
|
|
||||||
value: '${metrics.activeReservations}',
|
|
||||||
icon: Icons.event_available,
|
|
||||||
color: const Color(0xFF0E9F6E),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
Row(
|
_KpiGrid(
|
||||||
children: [
|
kpis: metrics.kpis,
|
||||||
Expanded(
|
canViewRevenue: canViewRevenue,
|
||||||
child: StatCard(
|
),
|
||||||
title: 'Revenue',
|
if (pendingOnline > 0) ...[
|
||||||
value:
|
const SizedBox(height: 16),
|
||||||
'\$${NumberFormat.compact().format(metrics.totalRevenue / 100)}',
|
InkWell(
|
||||||
icon: Icons.attach_money,
|
onTap: () => context.push('/dashboard/online-reservations'),
|
||||||
color: const Color(0xFF5521B5),
|
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),
|
||||||
const SizedBox(width: 12),
|
Expanded(
|
||||||
Expanded(
|
child: Text(
|
||||||
child: StatCard(
|
'$pendingOnline online reservation${pendingOnline == 1 ? '' : 's'} pending approval',
|
||||||
title: 'Customers',
|
style: const TextStyle(
|
||||||
value: '${metrics.totalCustomers}',
|
fontWeight: FontWeight.w600,
|
||||||
icon: Icons.people,
|
color: Color(0xFF92400E),
|
||||||
color: const Color(0xFFB45309),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const Icon(Icons.chevron_right, color: Color(0xFFFF6B00)),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
Card(
|
],
|
||||||
child: Padding(
|
const SizedBox(height: 16),
|
||||||
padding: const EdgeInsets.all(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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Row(
|
||||||
'Fleet Occupancy',
|
children: [
|
||||||
style: TextStyle(
|
Expanded(
|
||||||
fontWeight: FontWeight.w600,
|
child: Text(
|
||||||
color: Color(0xFF374151),
|
_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(
|
LinearProgressIndicator(
|
||||||
value: metrics.occupancyRate / 100,
|
value: maxCount == 0 ? 0 : item.count / maxCount,
|
||||||
backgroundColor: const Color(0xFFE5E7EB),
|
minHeight: 8,
|
||||||
|
borderRadius: BorderRadius.circular(99),
|
||||||
|
backgroundColor: cs.surfaceContainerHighest,
|
||||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||||
Color(0xFF1A56DB),
|
Color(0xFF1A56DB),
|
||||||
),
|
),
|
||||||
minHeight: 8,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
if (canViewRevenue) ...[
|
||||||
Text(
|
const SizedBox(height: 6),
|
||||||
'${metrics.occupancyRate.toStringAsFixed(1)}% occupied',
|
Text(
|
||||||
style: const TextStyle(
|
_formatMoney(item.revenue),
|
||||||
fontSize: 13,
|
style: TextStyle(
|
||||||
color: Color(0xFF6B7280),
|
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,
|
class _RecentReservationsSection extends StatelessWidget {
|
||||||
vertical: 14,
|
final List<DashboardRecentReservation> analyticsReservations;
|
||||||
),
|
final List<Reservation> fallbackReservations;
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFFFF3CD),
|
const _RecentReservationsSection({
|
||||||
borderRadius: BorderRadius.circular(12),
|
required this.analyticsReservations,
|
||||||
border: Border.all(color: const Color(0xFFFF6B00)),
|
required this.fallbackReservations,
|
||||||
),
|
});
|
||||||
child: Row(
|
|
||||||
children: [
|
@override
|
||||||
const Icon(
|
Widget build(BuildContext context) {
|
||||||
Icons.public,
|
final cs = Theme.of(context).colorScheme;
|
||||||
color: Color(0xFFFF6B00),
|
|
||||||
size: 22,
|
return Card(
|
||||||
),
|
child: Padding(
|
||||||
const SizedBox(width: 12),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
Expanded(
|
child: Column(
|
||||||
child: Text(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
'$pendingOnline online request${pendingOnline == 1 ? '' : 's'} pending approval',
|
children: [
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Color(0xFF92400E),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(Icons.chevron_right, color: Color(0xFFFF6B00)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Recent Reservations',
|
'Recent Reservations',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.w700,
|
||||||
color: Color(0xFF111928),
|
color: cs.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -218,37 +414,31 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
recentAsync.when(
|
if (analyticsReservations.isNotEmpty)
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
...analyticsReservations.map(
|
||||||
error: (e, _) => const ErrorView(
|
(reservation) => _DashboardRecentReservationCard(
|
||||||
message: 'Could not load recent reservations',
|
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 {
|
class _AvatarMenu extends ConsumerWidget {
|
||||||
final dynamic employee;
|
final dynamic employee;
|
||||||
|
|
||||||
@@ -263,6 +653,7 @@ class _AvatarMenu extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
final initial = employee?.firstName.substring(0, 1).toUpperCase() ?? 'U';
|
final initial = employee?.firstName.substring(0, 1).toUpperCase() ?? 'U';
|
||||||
final name = employee?.fullName ?? 'Employee';
|
final name = employee?.fullName ?? 'Employee';
|
||||||
final role = employee?.role ?? '';
|
final role = employee?.role ?? '';
|
||||||
@@ -309,18 +700,18 @@ class _AvatarMenu extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
name,
|
name,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF111928),
|
color: cs.onSurface,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (role.isNotEmpty)
|
if (role.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
role,
|
role,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Color(0xFF6B7280),
|
color: cs.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
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/error_view.dart';
|
||||||
import '../../../widgets/status_badge.dart';
|
import '../../../widgets/status_badge.dart';
|
||||||
|
|
||||||
const _params = {
|
const ReservationQuery _params = (
|
||||||
'status': 'DRAFT',
|
status: 'DRAFT',
|
||||||
'source': 'MARKETPLACE',
|
source: 'MARKETPLACE',
|
||||||
'page': 1,
|
page: 1,
|
||||||
'pageSize': 100,
|
pageSize: 100,
|
||||||
};
|
search: null,
|
||||||
|
customerId: null,
|
||||||
|
);
|
||||||
|
|
||||||
class OnlineReservationsScreen extends ConsumerWidget {
|
class OnlineReservationsScreen extends ConsumerWidget {
|
||||||
const OnlineReservationsScreen({super.key});
|
const OnlineReservationsScreen({super.key});
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
|||||||
DateTime _end = DateTime.now();
|
DateTime _end = DateTime.now();
|
||||||
String? _statusFilter;
|
String? _statusFilter;
|
||||||
|
|
||||||
Map<String, String> get _params => {
|
ReportQuery get _params => (
|
||||||
'startDate':
|
startDate:
|
||||||
'${_start.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
'${_start.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
||||||
'endDate': '${_end.toIso8601String().substring(0, 10)}T23:59:59.000Z',
|
endDate: '${_end.toIso8601String().substring(0, 10)}T23:59:59.000Z',
|
||||||
'status': ?_statusFilter,
|
status: _statusFilter,
|
||||||
};
|
);
|
||||||
|
|
||||||
Future<void> _pickRange() async {
|
Future<void> _pickRange() async {
|
||||||
final range = await showDateRangePicker(
|
final range = await showDateRangePicker(
|
||||||
|
|||||||
@@ -52,12 +52,14 @@ class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _params(int tabIndex) => {
|
ReservationQuery _params(int tabIndex) => (
|
||||||
'page': 1,
|
page: 1,
|
||||||
'pageSize': 50,
|
pageSize: 50,
|
||||||
if (_statuses[tabIndex] != null) 'status': _statuses[tabIndex],
|
status: _statuses[tabIndex],
|
||||||
if (_search.isNotEmpty) 'search': _search,
|
source: null,
|
||||||
};
|
search: _search.isNotEmpty ? _search : null,
|
||||||
|
customerId: null,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -116,7 +118,7 @@ class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ReservationTab extends ConsumerWidget {
|
class _ReservationTab extends ConsumerWidget {
|
||||||
final Map<String, dynamic> params;
|
final ReservationQuery params;
|
||||||
final void Function(String id) onTap;
|
final void Function(String id) onTap;
|
||||||
|
|
||||||
const _ReservationTab({required this.params, required this.onTap});
|
const _ReservationTab({required this.params, required this.onTap});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import '../../../core/models/vehicle.dart';
|
||||||
import '../providers/dashboard_providers.dart';
|
import '../providers/dashboard_providers.dart';
|
||||||
import '../../../widgets/vehicle_card.dart';
|
import '../../../widgets/vehicle_card.dart';
|
||||||
import '../../../widgets/loading_list.dart';
|
import '../../../widgets/loading_list.dart';
|
||||||
@@ -26,12 +28,45 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> get _params => {
|
VehicleQuery get _params => (
|
||||||
'page': 1,
|
page: 1,
|
||||||
'pageSize': 50,
|
pageSize: 100,
|
||||||
if (_statusFilter != null) 'status': _statusFilter,
|
status: null,
|
||||||
if (_search.isNotEmpty) 'search': _search,
|
);
|
||||||
};
|
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -48,7 +83,7 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
|
|||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
),
|
),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Vehicles'),
|
title: const Text('Fleet'),
|
||||||
bottom: PreferredSize(
|
bottom: PreferredSize(
|
||||||
preferredSize: const Size.fromHeight(60),
|
preferredSize: const Size.fromHeight(60),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -86,34 +121,40 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
|
|||||||
body: vehiclesAsync.when(
|
body: vehiclesAsync.when(
|
||||||
loading: () => const LoadingList(itemHeight: 240),
|
loading: () => const LoadingList(itemHeight: 240),
|
||||||
error: (e, _) => ErrorView(
|
error: (e, _) => ErrorView(
|
||||||
message: 'Failed to load vehicles',
|
message: _errorMessage(e),
|
||||||
onRetry: () => ref.invalidate(vehiclesProvider(_params)),
|
onRetry: () => ref.invalidate(vehiclesProvider(_params)),
|
||||||
),
|
),
|
||||||
data: (res) => res.data.isEmpty
|
data: (res) {
|
||||||
? const Center(
|
final filteredVehicles = _filterVehicles(res.data);
|
||||||
|
|
||||||
|
return filteredVehicles.isEmpty
|
||||||
|
? const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No vehicles found',
|
'No vehicles found',
|
||||||
style: TextStyle(color: Color(0xFF9CA3AF)),
|
style: TextStyle(color: Color(0xFF9CA3AF)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async =>
|
onRefresh: () async =>
|
||||||
ref.invalidate(vehiclesProvider(_params)),
|
ref.invalidate(vehiclesProvider(_params)),
|
||||||
child: GridView.builder(
|
child: GridView.builder(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate:
|
||||||
crossAxisCount: 1,
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
mainAxisExtent: 240,
|
crossAxisCount: 1,
|
||||||
mainAxisSpacing: 12,
|
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",
|
"@@locale": "ar",
|
||||||
"appName": "RentalDriveGo",
|
"appName": "KriTomobil",
|
||||||
"tagline": "أسهل طريقة للعثور\nوحجز سيارتك المثالية",
|
"tagline": "أسهل طريقة للعثور\nوحجز سيارتك المثالية",
|
||||||
"findYourCar": "ابحث عن سيارتك",
|
"findYourCar": "ابحث عن سيارتك",
|
||||||
"companySignIn": "وكالة السيارات",
|
"companySignIn": "وكالة السيارات",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "en",
|
"@@locale": "en",
|
||||||
"appName": "RentalDriveGo",
|
"appName": "KriTomobil",
|
||||||
"tagline": "The easiest way to find\nand reserve your perfect car",
|
"tagline": "The easiest way to find\nand reserve your perfect car",
|
||||||
"findYourCar": "Find your car",
|
"findYourCar": "Find your car",
|
||||||
"companySignIn": "Space Agency",
|
"companySignIn": "Space Agency",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "fr",
|
"@@locale": "fr",
|
||||||
"appName": "RentalDriveGo",
|
"appName": "KriTomobil",
|
||||||
"tagline": "La façon la plus simple de trouver\net réserver la voiture parfaite",
|
"tagline": "La façon la plus simple de trouver\net réserver la voiture parfaite",
|
||||||
"findYourCar": "Trouver ma voiture",
|
"findYourCar": "Trouver ma voiture",
|
||||||
"companySignIn": "Espace Agence",
|
"companySignIn": "Espace Agence",
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ abstract class AppLocalizations {
|
|||||||
/// No description provided for @appName.
|
/// No description provided for @appName.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'RentalDriveGo'**
|
/// **'KriTomobil'**
|
||||||
String get appName;
|
String get appName;
|
||||||
|
|
||||||
/// No description provided for @tagline.
|
/// No description provided for @tagline.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
AppLocalizationsAr([String locale = 'ar']) : super(locale);
|
AppLocalizationsAr([String locale = 'ar']) : super(locale);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appName => 'RentalDriveGo';
|
String get appName => 'KriTomobil';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tagline => 'أسهل طريقة للعثور\nوحجز سيارتك المثالية';
|
String get tagline => 'أسهل طريقة للعثور\nوحجز سيارتك المثالية';
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appName => 'RentalDriveGo';
|
String get appName => 'KriTomobil';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tagline => 'The easiest way to find\nand reserve your perfect car';
|
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);
|
AppLocalizationsFr([String locale = 'fr']) : super(locale);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appName => 'RentalDriveGo';
|
String get appName => 'KriTomobil';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tagline =>
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
@@ -19,7 +21,7 @@ class ErrorView extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
message,
|
message,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(color: Color(0xFF6B7280)),
|
style: TextStyle(color: cs.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
if (onRetry != null) ...[
|
if (onRetry != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ class LoadingList extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
return Shimmer.fromColors(
|
return Shimmer.fromColors(
|
||||||
baseColor: const Color(0xFFE5E7EB),
|
baseColor: isDark ? const Color(0xFF2F3145) : const Color(0xFFE5E7EB),
|
||||||
highlightColor: const Color(0xFFF9FAFB),
|
highlightColor:
|
||||||
|
isDark ? const Color(0xFF3A3C54) : const Color(0xFFF9FAFB),
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: count,
|
itemCount: count,
|
||||||
@@ -19,7 +22,7 @@ class LoadingList extends StatelessWidget {
|
|||||||
itemBuilder: (_, _) => Container(
|
itemBuilder: (_, _) => Container(
|
||||||
height: itemHeight,
|
height: itemHeight,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: isDark ? const Color(0xFF28293A) : Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class ReservationCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fmt = DateFormat('MMM d, yyyy');
|
final fmt = DateFormat('MMM d, yyyy');
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
@@ -39,34 +41,44 @@ class ReservationCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
reservation.customer!.fullName,
|
reservation.customer!.fullName,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13, color: Color(0xFF6B7280)),
|
fontSize: 13,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.calendar_today,
|
Icon(
|
||||||
size: 14, color: Color(0xFF9CA3AF)),
|
Icons.calendar_today,
|
||||||
|
size: 14,
|
||||||
|
color: cs.onSurfaceVariant.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'${fmt.format(reservation.startDate)} → ${fmt.format(reservation.endDate)}',
|
'${fmt.format(reservation.startDate)} → ${fmt.format(reservation.endDate)}',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13, color: Color(0xFF6B7280)),
|
fontSize: 13,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.attach_money,
|
Icon(
|
||||||
size: 14, color: Color(0xFF9CA3AF)),
|
Icons.attach_money,
|
||||||
|
size: 14,
|
||||||
|
color: cs.onSurfaceVariant.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'\$${(reservation.totalAmount / 100).toStringAsFixed(2)}',
|
'\$${(reservation.totalAmount / 100).toStringAsFixed(2)}',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF111928),
|
color: cs.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ class StatCard extends StatelessWidget {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final Color color;
|
final Color color;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
|
final double? change;
|
||||||
|
final String? footerLabel;
|
||||||
|
|
||||||
const StatCard({
|
const StatCard({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -14,10 +16,14 @@ class StatCard extends StatelessWidget {
|
|||||||
required this.icon,
|
required this.icon,
|
||||||
required this.color,
|
required this.color,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
|
this.change,
|
||||||
|
this.footerLabel,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -40,30 +46,62 @@ class StatCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Color(0xFF111928),
|
color: cs.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: Color(0xFF6B7280),
|
color: cs.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (subtitle != null) ...[
|
if (subtitle != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
subtitle!,
|
subtitle!,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -44,9 +46,9 @@ class VehicleCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
vehicle.licensePlate,
|
vehicle.licensePlate,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: Color(0xFF6B7280),
|
color: cs.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -66,13 +68,15 @@ class VehicleCard extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 8, vertical: 3),
|
horizontal: 8, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF3F4F6),
|
color: cs.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
vehicle.category,
|
vehicle.category,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11, color: Color(0xFF6B7280)),
|
fontSize: 11,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user