first app design
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'core/router/app_router.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'core/providers/locale_provider.dart';
|
||||
import 'core/providers/theme_provider.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
|
||||
class CarRentalApp extends ConsumerWidget {
|
||||
const CarRentalApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(routerProvider);
|
||||
final locale = ref.watch(localeProvider);
|
||||
final themeMode = ref.watch(themeProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'RentalDriveGo',
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
themeMode: themeMode,
|
||||
routerConfig: router,
|
||||
debugShowCheckedModeBanner: false,
|
||||
locale: locale,
|
||||
supportedLocales: const [
|
||||
Locale('en'),
|
||||
Locale('fr'),
|
||||
Locale('ar'),
|
||||
],
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'dart:io';
|
||||
|
||||
class AppConstants {
|
||||
// Android emulator routes localhost as 10.0.2.2; iOS simulator uses localhost fine
|
||||
static String get baseUrl => Platform.isAndroid
|
||||
? 'http://10.0.2.2:4000/api/v1'
|
||||
: 'http://localhost:4000/api/v1';
|
||||
|
||||
static const String tokenKey = 'auth_token';
|
||||
static const String userTypeKey = 'user_type';
|
||||
|
||||
static const String userTypeEmployee = 'employee';
|
||||
static const String userTypeRenter = 'renter';
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
class DashboardMetrics {
|
||||
final int totalReservations;
|
||||
final int activeReservations;
|
||||
final double totalRevenue;
|
||||
final int totalVehicles;
|
||||
final int availableVehicles;
|
||||
final int totalCustomers;
|
||||
final double occupancyRate;
|
||||
|
||||
const DashboardMetrics({
|
||||
required this.totalReservations,
|
||||
required this.activeReservations,
|
||||
required this.totalRevenue,
|
||||
required this.totalVehicles,
|
||||
required this.availableVehicles,
|
||||
required this.totalCustomers,
|
||||
required this.occupancyRate,
|
||||
});
|
||||
|
||||
factory DashboardMetrics.fromJson(Map<String, dynamic> json) =>
|
||||
DashboardMetrics(
|
||||
totalReservations: json['totalReservations'] as int? ?? 0,
|
||||
activeReservations: json['activeReservations'] as int? ?? 0,
|
||||
totalRevenue: (json['totalRevenue'] as num?)?.toDouble() ?? 0,
|
||||
totalVehicles: json['totalVehicles'] as int? ?? 0,
|
||||
availableVehicles: json['availableVehicles'] as int? ?? 0,
|
||||
totalCustomers: json['totalCustomers'] as int? ?? 0,
|
||||
occupancyRate: (json['occupancyRate'] as num?)?.toDouble() ?? 0,
|
||||
);
|
||||
|
||||
factory DashboardMetrics.empty() => const DashboardMetrics(
|
||||
totalReservations: 0,
|
||||
activeReservations: 0,
|
||||
totalRevenue: 0,
|
||||
totalVehicles: 0,
|
||||
availableVehicles: 0,
|
||||
totalCustomers: 0,
|
||||
occupancyRate: 0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
class Employee {
|
||||
final String id;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String email;
|
||||
final String role;
|
||||
final bool isActive;
|
||||
final String? companyId;
|
||||
|
||||
const Employee({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.email,
|
||||
required this.role,
|
||||
required this.isActive,
|
||||
this.companyId,
|
||||
});
|
||||
|
||||
String get fullName => '$firstName $lastName';
|
||||
|
||||
factory Employee.fromJson(Map<String, dynamic> json) => Employee(
|
||||
id: json['id'] as String,
|
||||
firstName: json['firstName'] as String,
|
||||
lastName: json['lastName'] as String,
|
||||
email: json['email'] as String,
|
||||
role: json['role'] as String,
|
||||
isActive: json['isActive'] as bool? ?? true,
|
||||
companyId: json['companyId'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class Renter {
|
||||
final String id;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String email;
|
||||
|
||||
const Renter({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.email,
|
||||
});
|
||||
|
||||
String get fullName => '$firstName $lastName';
|
||||
|
||||
factory Renter.fromJson(Map<String, dynamic> json) => Renter(
|
||||
id: json['id'] as String,
|
||||
firstName: json['firstName'] as String,
|
||||
lastName: json['lastName'] as String,
|
||||
email: json['email'] as String,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
class Customer {
|
||||
final String id;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String? email;
|
||||
final String? phone;
|
||||
final String licenseStatus;
|
||||
final bool isFlagged;
|
||||
final String? flagReason;
|
||||
final DateTime? dateOfBirth;
|
||||
final String? nationality;
|
||||
final DateTime createdAt;
|
||||
|
||||
const Customer({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
this.email,
|
||||
this.phone,
|
||||
required this.licenseStatus,
|
||||
required this.isFlagged,
|
||||
this.flagReason,
|
||||
this.dateOfBirth,
|
||||
this.nationality,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
String get fullName => '$firstName $lastName';
|
||||
|
||||
factory Customer.fromJson(Map<String, dynamic> json) => Customer(
|
||||
id: json['id'] as String,
|
||||
firstName: json['firstName'] as String,
|
||||
lastName: json['lastName'] as String,
|
||||
email: json['email'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
licenseStatus: json['licenseStatus'] as String? ?? 'PENDING',
|
||||
isFlagged: json['isFlagged'] as bool? ?? false,
|
||||
flagReason: json['flagReason'] as String?,
|
||||
dateOfBirth: json['dateOfBirth'] != null
|
||||
? DateTime.tryParse(json['dateOfBirth'] as String)
|
||||
: null,
|
||||
nationality: json['nationality'] as String?,
|
||||
createdAt: DateTime.parse(
|
||||
json['createdAt'] as String? ?? DateTime.now().toIso8601String()),
|
||||
);
|
||||
}
|
||||
|
||||
class CustomerListResponse {
|
||||
final List<Customer> data;
|
||||
final int total;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
|
||||
const CustomerListResponse({
|
||||
required this.data,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
});
|
||||
|
||||
factory CustomerListResponse.fromJson(Map<String, dynamic> json) =>
|
||||
CustomerListResponse(
|
||||
data: (json['data'] as List<dynamic>)
|
||||
.map((e) => Customer.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
total: json['total'] as int? ?? 0,
|
||||
page: json['page'] as int? ?? 1,
|
||||
pageSize: json['pageSize'] as int? ?? 20,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
class CompanyBrand {
|
||||
final String displayName;
|
||||
final String? logoUrl;
|
||||
final String subdomain;
|
||||
final double? marketplaceRating;
|
||||
final String primaryColor;
|
||||
|
||||
const CompanyBrand({
|
||||
required this.displayName,
|
||||
this.logoUrl,
|
||||
required this.subdomain,
|
||||
this.marketplaceRating,
|
||||
required this.primaryColor,
|
||||
});
|
||||
|
||||
factory CompanyBrand.fromJson(Map<String, dynamic> json) => CompanyBrand(
|
||||
displayName: json['displayName'] as String? ?? '',
|
||||
logoUrl: json['logoUrl'] as String?,
|
||||
subdomain: json['subdomain'] as String? ?? '',
|
||||
marketplaceRating:
|
||||
(json['marketplaceRating'] as num?)?.toDouble(),
|
||||
primaryColor: json['primaryColor'] as String? ?? '#1A56DB',
|
||||
);
|
||||
}
|
||||
|
||||
class MarketplaceCompany {
|
||||
final String id;
|
||||
final CompanyBrand brand;
|
||||
|
||||
const MarketplaceCompany({required this.id, required this.brand});
|
||||
|
||||
factory MarketplaceCompany.fromJson(Map<String, dynamic> json) =>
|
||||
MarketplaceCompany(
|
||||
id: json['id'] as String,
|
||||
brand: CompanyBrand.fromJson(
|
||||
json['brand'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
class MarketplaceVehicle {
|
||||
final String id;
|
||||
final String companyId;
|
||||
final String make;
|
||||
final String model;
|
||||
final int year;
|
||||
final String? color;
|
||||
final String licensePlate;
|
||||
final String category;
|
||||
final int seats;
|
||||
final String transmission;
|
||||
final String fuelType;
|
||||
final List<String> features;
|
||||
// dailyRate comes in cents from the API
|
||||
final int dailyRateCents;
|
||||
final String status;
|
||||
final List<String> photos;
|
||||
final int? mileage;
|
||||
final bool isPublished;
|
||||
final MarketplaceCompany company;
|
||||
final bool availability;
|
||||
final String availabilityStatus;
|
||||
final DateTime? nextAvailableAt;
|
||||
|
||||
const MarketplaceVehicle({
|
||||
required this.id,
|
||||
required this.companyId,
|
||||
required this.make,
|
||||
required this.model,
|
||||
required this.year,
|
||||
this.color,
|
||||
required this.licensePlate,
|
||||
required this.category,
|
||||
required this.seats,
|
||||
required this.transmission,
|
||||
required this.fuelType,
|
||||
required this.features,
|
||||
required this.dailyRateCents,
|
||||
required this.status,
|
||||
required this.photos,
|
||||
this.mileage,
|
||||
required this.isPublished,
|
||||
required this.company,
|
||||
required this.availability,
|
||||
required this.availabilityStatus,
|
||||
this.nextAvailableAt,
|
||||
});
|
||||
|
||||
String get displayName => '$year $make $model';
|
||||
String? get primaryPhoto => photos.isNotEmpty ? photos.first : null;
|
||||
// Convert cents to dollars for display
|
||||
double get dailyRate => dailyRateCents / 100;
|
||||
String get companySlug => company.brand.subdomain;
|
||||
|
||||
factory MarketplaceVehicle.fromJson(Map<String, dynamic> json) =>
|
||||
MarketplaceVehicle(
|
||||
id: json['id'] as String,
|
||||
companyId: json['companyId'] as String,
|
||||
make: json['make'] as String,
|
||||
model: json['model'] as String,
|
||||
year: json['year'] as int,
|
||||
color: json['color'] as String?,
|
||||
licensePlate: json['licensePlate'] as String,
|
||||
category: json['category'] as String? ?? 'ECONOMY',
|
||||
seats: json['seats'] as int? ?? 5,
|
||||
transmission: json['transmission'] as String? ?? 'MANUAL',
|
||||
fuelType: json['fuelType'] as String? ?? 'GASOLINE',
|
||||
features: (json['features'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[],
|
||||
dailyRateCents: json['dailyRate'] as int? ?? 0,
|
||||
status: json['status'] as String? ?? 'AVAILABLE',
|
||||
photos: (json['photos'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[],
|
||||
mileage: json['mileage'] as int?,
|
||||
isPublished: json['isPublished'] as bool? ?? false,
|
||||
company: MarketplaceCompany.fromJson(
|
||||
json['company'] as Map<String, dynamic>),
|
||||
availability: json['availability'] as bool? ?? true,
|
||||
availabilityStatus:
|
||||
json['availabilityStatus'] as String? ?? 'AVAILABLE',
|
||||
nextAvailableAt: json['nextAvailableAt'] != null
|
||||
? DateTime.tryParse(json['nextAvailableAt'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
class MarketplaceSearchResult {
|
||||
final List<MarketplaceVehicle> data;
|
||||
final int total;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
|
||||
const MarketplaceSearchResult({
|
||||
required this.data,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
});
|
||||
|
||||
factory MarketplaceSearchResult.fromJson(Map<String, dynamic> json) {
|
||||
final raw = json['data'] ?? json;
|
||||
if (raw is List) {
|
||||
return MarketplaceSearchResult(
|
||||
data: raw
|
||||
.map((e) =>
|
||||
MarketplaceVehicle.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
total: json['total'] as int? ?? raw.length,
|
||||
page: json['page'] as int? ?? 1,
|
||||
pageSize: json['pageSize'] as int? ?? 20,
|
||||
);
|
||||
}
|
||||
return MarketplaceSearchResult(
|
||||
data: (json['data'] as List<dynamic>)
|
||||
.map((e) =>
|
||||
MarketplaceVehicle.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
total: json['total'] as int? ?? 0,
|
||||
page: json['page'] as int? ?? 1,
|
||||
pageSize: json['pageSize'] as int? ?? 20,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MarketplaceOffer {
|
||||
final String id;
|
||||
final String title;
|
||||
final String? description;
|
||||
final String? imageUrl;
|
||||
final String type;
|
||||
final int discountValue;
|
||||
final String? promoCode;
|
||||
final bool isFeatured;
|
||||
final DateTime validUntil;
|
||||
final MarketplaceCompany company;
|
||||
|
||||
const MarketplaceOffer({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.description,
|
||||
this.imageUrl,
|
||||
required this.type,
|
||||
required this.discountValue,
|
||||
this.promoCode,
|
||||
required this.isFeatured,
|
||||
required this.validUntil,
|
||||
required this.company,
|
||||
});
|
||||
|
||||
String get discountLabel {
|
||||
switch (type) {
|
||||
case 'PERCENTAGE':
|
||||
return '${discountValue}% off';
|
||||
case 'FIXED_AMOUNT':
|
||||
return '\$${(discountValue / 100).toStringAsFixed(0)} off';
|
||||
case 'FREE_DAY':
|
||||
return '$discountValue free day(s)';
|
||||
case 'SPECIAL_RATE':
|
||||
return 'Special rate';
|
||||
default:
|
||||
return 'Offer';
|
||||
}
|
||||
}
|
||||
|
||||
factory MarketplaceOffer.fromJson(Map<String, dynamic> json) =>
|
||||
MarketplaceOffer(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String?,
|
||||
imageUrl: json['imageUrl'] as String?,
|
||||
type: json['type'] as String,
|
||||
discountValue: json['discountValue'] as int? ?? 0,
|
||||
promoCode: json['promoCode'] as String?,
|
||||
isFeatured: json['isFeatured'] as bool? ?? false,
|
||||
validUntil:
|
||||
DateTime.parse(json['validUntil'] as String),
|
||||
company: MarketplaceCompany.fromJson(
|
||||
json['company'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
class Reservation {
|
||||
final String id;
|
||||
final String status;
|
||||
final DateTime startDate;
|
||||
final DateTime endDate;
|
||||
final double totalAmount;
|
||||
final String paymentStatus;
|
||||
final String? vehicleId;
|
||||
final String? customerId;
|
||||
final VehicleSummary? vehicle;
|
||||
final CustomerSummary? customer;
|
||||
final DateTime createdAt;
|
||||
final String? source;
|
||||
|
||||
const Reservation({
|
||||
required this.id,
|
||||
required this.status,
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
required this.totalAmount,
|
||||
required this.paymentStatus,
|
||||
this.vehicleId,
|
||||
this.customerId,
|
||||
this.vehicle,
|
||||
this.customer,
|
||||
required this.createdAt,
|
||||
this.source,
|
||||
});
|
||||
|
||||
int get days => endDate.difference(startDate).inDays;
|
||||
|
||||
factory Reservation.fromJson(Map<String, dynamic> json) => Reservation(
|
||||
id: json['id'] as String,
|
||||
status: json['status'] as String,
|
||||
startDate: DateTime.parse(json['startDate'] as String),
|
||||
endDate: DateTime.parse(json['endDate'] as String),
|
||||
totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? 0,
|
||||
paymentStatus: json['paymentStatus'] as String? ?? 'PENDING',
|
||||
vehicleId: json['vehicleId'] as String?,
|
||||
customerId: json['customerId'] as String?,
|
||||
vehicle: json['vehicle'] != null
|
||||
? VehicleSummary.fromJson(
|
||||
json['vehicle'] as Map<String, dynamic>)
|
||||
: null,
|
||||
customer: json['customer'] != null
|
||||
? CustomerSummary.fromJson(
|
||||
json['customer'] as Map<String, dynamic>)
|
||||
: null,
|
||||
createdAt: DateTime.parse(
|
||||
json['createdAt'] as String? ?? DateTime.now().toIso8601String()),
|
||||
source: json['source'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class VehicleSummary {
|
||||
final String id;
|
||||
final String make;
|
||||
final String model;
|
||||
final int year;
|
||||
final String licensePlate;
|
||||
final List<String> photos;
|
||||
|
||||
const VehicleSummary({
|
||||
required this.id,
|
||||
required this.make,
|
||||
required this.model,
|
||||
required this.year,
|
||||
required this.licensePlate,
|
||||
required this.photos,
|
||||
});
|
||||
|
||||
String get displayName => '$year $make $model';
|
||||
String? get primaryPhoto => photos.isNotEmpty ? photos.first : null;
|
||||
|
||||
factory VehicleSummary.fromJson(Map<String, dynamic> json) => VehicleSummary(
|
||||
id: json['id'] as String,
|
||||
make: json['make'] as String,
|
||||
model: json['model'] as String,
|
||||
year: json['year'] as int,
|
||||
licensePlate: json['licensePlate'] as String,
|
||||
photos: (json['photos'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
class CustomerSummary {
|
||||
final String id;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String? email;
|
||||
final String? phone;
|
||||
|
||||
const CustomerSummary({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
this.email,
|
||||
this.phone,
|
||||
});
|
||||
|
||||
String get fullName => '$firstName $lastName';
|
||||
|
||||
factory CustomerSummary.fromJson(Map<String, dynamic> json) =>
|
||||
CustomerSummary(
|
||||
id: json['id'] as String,
|
||||
firstName: json['firstName'] as String,
|
||||
lastName: json['lastName'] as String,
|
||||
email: json['email'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class ReservationListResponse {
|
||||
final List<Reservation> data;
|
||||
final int total;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
|
||||
const ReservationListResponse({
|
||||
required this.data,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
});
|
||||
|
||||
factory ReservationListResponse.fromJson(Map<String, dynamic> json) =>
|
||||
ReservationListResponse(
|
||||
data: (json['data'] as List<dynamic>)
|
||||
.map((e) => Reservation.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
total: json['total'] as int? ?? 0,
|
||||
page: json['page'] as int? ?? 1,
|
||||
pageSize: json['pageSize'] as int? ?? 20,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
class Vehicle {
|
||||
final String id;
|
||||
final String make;
|
||||
final String model;
|
||||
final int year;
|
||||
final String licensePlate;
|
||||
final double dailyRate;
|
||||
final String status;
|
||||
final String category;
|
||||
final List<String> photos;
|
||||
final int? mileage;
|
||||
final int? seats;
|
||||
final String? transmission;
|
||||
final String? fuelType;
|
||||
final bool isPublished;
|
||||
|
||||
const Vehicle({
|
||||
required this.id,
|
||||
required this.make,
|
||||
required this.model,
|
||||
required this.year,
|
||||
required this.licensePlate,
|
||||
required this.dailyRate,
|
||||
required this.status,
|
||||
required this.category,
|
||||
required this.photos,
|
||||
this.mileage,
|
||||
this.seats,
|
||||
this.transmission,
|
||||
this.fuelType,
|
||||
required this.isPublished,
|
||||
});
|
||||
|
||||
String get displayName => '$year $make $model';
|
||||
|
||||
String? get primaryPhoto => photos.isNotEmpty ? photos.first : null;
|
||||
|
||||
factory Vehicle.fromJson(Map<String, dynamic> json) => Vehicle(
|
||||
id: json['id'] as String,
|
||||
make: json['make'] as String,
|
||||
model: json['model'] as String,
|
||||
year: json['year'] as int,
|
||||
licensePlate: json['licensePlate'] as String,
|
||||
dailyRate: (json['dailyRate'] as num).toDouble(),
|
||||
status: json['status'] as String? ?? 'AVAILABLE',
|
||||
category: json['category'] as String? ?? 'ECONOMY',
|
||||
photos: (json['photos'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[],
|
||||
mileage: json['mileage'] as int?,
|
||||
seats: json['seats'] as int?,
|
||||
transmission: json['transmission'] as String?,
|
||||
fuelType: json['fuelType'] as String?,
|
||||
isPublished: json['isPublished'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
class VehicleListResponse {
|
||||
final List<Vehicle> data;
|
||||
final int total;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
|
||||
const VehicleListResponse({
|
||||
required this.data,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
});
|
||||
|
||||
factory VehicleListResponse.fromJson(Map<String, dynamic> json) =>
|
||||
VehicleListResponse(
|
||||
data: (json['data'] as List<dynamic>)
|
||||
.map((e) => Vehicle.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
total: json['total'] as int? ?? 0,
|
||||
page: json['page'] as int? ?? 1,
|
||||
pageSize: json['pageSize'] as int? ?? 20,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
import '../models/auth_models.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../storage/token_storage.dart';
|
||||
|
||||
enum AuthStatus { unknown, authenticated, unauthenticated }
|
||||
|
||||
class AuthState {
|
||||
final AuthStatus status;
|
||||
final String? userType;
|
||||
final Employee? employee;
|
||||
final Renter? renter;
|
||||
|
||||
const AuthState({
|
||||
required this.status,
|
||||
this.userType,
|
||||
this.employee,
|
||||
this.renter,
|
||||
});
|
||||
|
||||
AuthState copyWith({
|
||||
AuthStatus? status,
|
||||
String? userType,
|
||||
Employee? employee,
|
||||
Renter? renter,
|
||||
}) =>
|
||||
AuthState(
|
||||
status: status ?? this.status,
|
||||
userType: userType ?? this.userType,
|
||||
employee: employee ?? this.employee,
|
||||
renter: renter ?? this.renter,
|
||||
);
|
||||
}
|
||||
|
||||
class AuthNotifier extends StateNotifier<AuthState> {
|
||||
final AuthService _service;
|
||||
|
||||
AuthNotifier(this._service)
|
||||
: super(const AuthState(status: AuthStatus.unknown));
|
||||
|
||||
Future<void> init() async {
|
||||
final token = await TokenStorage.getToken();
|
||||
final userType = await TokenStorage.getUserType();
|
||||
if (token == null || userType == null) {
|
||||
state = const AuthState(status: AuthStatus.unauthenticated);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (userType == AppConstants.userTypeEmployee) {
|
||||
final employee = await _service.getMe();
|
||||
state = AuthState(
|
||||
status: AuthStatus.authenticated,
|
||||
userType: userType,
|
||||
employee: employee,
|
||||
);
|
||||
} else {
|
||||
final renter = await _service.getRenterMe();
|
||||
state = AuthState(
|
||||
status: AuthStatus.authenticated,
|
||||
userType: userType,
|
||||
renter: renter,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
await TokenStorage.clear();
|
||||
state = const AuthState(status: AuthStatus.unauthenticated);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loginEmployee(String email, String password) async {
|
||||
final result = await _service.loginEmployee(email: email, password: password);
|
||||
await TokenStorage.saveToken(result.token);
|
||||
await TokenStorage.saveUserType(AppConstants.userTypeEmployee);
|
||||
state = AuthState(
|
||||
status: AuthStatus.authenticated,
|
||||
userType: AppConstants.userTypeEmployee,
|
||||
employee: result.employee,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _service.logout();
|
||||
state = const AuthState(status: AuthStatus.unauthenticated);
|
||||
}
|
||||
}
|
||||
|
||||
final authServiceProvider = Provider((_) => AuthService());
|
||||
|
||||
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||
return AuthNotifier(ref.read(authServiceProvider));
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
const _localeKey = 'app_locale';
|
||||
|
||||
const _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
class LocaleNotifier extends StateNotifier<Locale> {
|
||||
LocaleNotifier() : super(const Locale('en')) {
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final code = await _storage.read(key: _localeKey);
|
||||
if (code != null) state = Locale(code);
|
||||
}
|
||||
|
||||
Future<void> setLocale(Locale locale) async {
|
||||
await _storage.write(key: _localeKey, value: locale.languageCode);
|
||||
state = locale;
|
||||
}
|
||||
}
|
||||
|
||||
final localeProvider = StateNotifierProvider<LocaleNotifier, Locale>(
|
||||
(_) => LocaleNotifier(),
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
const _themeKey = 'app_theme';
|
||||
|
||||
const _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
class ThemeNotifier extends StateNotifier<ThemeMode> {
|
||||
ThemeNotifier() : super(ThemeMode.dark) {
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final val = await _storage.read(key: _themeKey);
|
||||
if (val == 'light') state = ThemeMode.light;
|
||||
if (val == 'dark') state = ThemeMode.dark;
|
||||
}
|
||||
|
||||
Future<void> setMode(ThemeMode mode) async {
|
||||
await _storage.write(
|
||||
key: _themeKey, value: mode == ThemeMode.light ? 'light' : 'dark');
|
||||
state = mode;
|
||||
}
|
||||
|
||||
void toggle() =>
|
||||
setMode(state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark);
|
||||
}
|
||||
|
||||
final themeProvider = StateNotifierProvider<ThemeNotifier, ThemeMode>(
|
||||
(_) => ThemeNotifier(),
|
||||
);
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
import '../models/marketplace.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../../features/auth/screens/splash_screen.dart';
|
||||
import '../../features/auth/screens/landing_screen.dart';
|
||||
import '../../features/auth/screens/role_screen.dart';
|
||||
import '../../features/auth/screens/employee_login_screen.dart';
|
||||
import '../../features/dashboard/screens/dashboard_shell.dart';
|
||||
import '../../features/dashboard/screens/home_screen.dart';
|
||||
import '../../features/dashboard/screens/vehicles_screen.dart';
|
||||
import '../../features/dashboard/screens/vehicle_detail_screen.dart';
|
||||
import '../../features/dashboard/screens/reservations_screen.dart';
|
||||
import '../../features/dashboard/screens/reservation_detail_screen.dart';
|
||||
import '../../features/dashboard/screens/customers_screen.dart';
|
||||
import '../../features/dashboard/screens/customer_detail_screen.dart';
|
||||
import '../../features/client/screens/client_shell.dart';
|
||||
import '../../features/client/screens/client_home_screen.dart';
|
||||
import '../../features/client/screens/client_vehicle_detail_screen.dart';
|
||||
import '../../features/client/screens/booking_screen.dart';
|
||||
import '../../features/client/screens/my_bookings_screen.dart';
|
||||
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
final router = GoRouter(
|
||||
initialLocation: '/splash',
|
||||
redirect: (context, state) {
|
||||
// Read (not watch) so redirect is evaluated on router.refresh()
|
||||
final auth = ref.read(authProvider);
|
||||
final status = auth.status;
|
||||
final loc = state.matchedLocation;
|
||||
final isSplash = loc == '/splash';
|
||||
final isLanding = loc == '/landing';
|
||||
final isLogin = loc.startsWith('/login');
|
||||
|
||||
// Still initialising — stay on splash
|
||||
if (status == AuthStatus.unknown) {
|
||||
return isSplash ? null : '/splash';
|
||||
}
|
||||
|
||||
// Auth resolved — always leave splash
|
||||
if (isSplash) {
|
||||
if (status == AuthStatus.authenticated) {
|
||||
return auth.userType == AppConstants.userTypeEmployee
|
||||
? '/dashboard'
|
||||
: '/client';
|
||||
}
|
||||
return '/landing';
|
||||
}
|
||||
|
||||
if (status == AuthStatus.unauthenticated) {
|
||||
// Allow landing, login routes, and the entire client section without auth
|
||||
if (isLanding || isLogin || loc.startsWith('/client')) return null;
|
||||
return '/landing';
|
||||
}
|
||||
|
||||
if (status == AuthStatus.authenticated) {
|
||||
if (isLogin) {
|
||||
return auth.userType == AppConstants.userTypeEmployee
|
||||
? '/dashboard'
|
||||
: '/client';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/splash',
|
||||
builder: (context, _) => const SplashScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/landing',
|
||||
builder: (context, _) => const LandingScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/role',
|
||||
builder: (context, _) => const RoleScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/login/employee',
|
||||
builder: (context, _) => const EmployeeLoginScreen(),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => DashboardShell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/dashboard',
|
||||
builder: (context, _) => const HomeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/dashboard/vehicles',
|
||||
builder: (context, _) => const VehiclesScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
builder: (_, state) =>
|
||||
VehicleDetailScreen(id: state.pathParameters['id']!),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/dashboard/reservations',
|
||||
builder: (context, _) => const ReservationsScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
builder: (_, state) =>
|
||||
ReservationDetailScreen(id: state.pathParameters['id']!),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/dashboard/customers',
|
||||
builder: (context, _) => const CustomersScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
builder: (_, state) =>
|
||||
CustomerDetailScreen(id: state.pathParameters['id']!),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => ClientShell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/client',
|
||||
builder: (context, _) => const ClientHomeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/client/vehicles/:id',
|
||||
builder: (_, state) => ClientVehicleDetailScreen(
|
||||
id: state.pathParameters['id']!,
|
||||
vehicle: state.extra as MarketplaceVehicle?,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/client/book/:vehicleId',
|
||||
builder: (_, state) => BookingScreen(
|
||||
vehicleId: state.pathParameters['vehicleId']!,
|
||||
vehicle: state.extra as MarketplaceVehicle?,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/client/bookings',
|
||||
builder: (context, _) => const MyBookingsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Trigger redirect re-evaluation whenever auth state changes
|
||||
ref.listen<AuthState>(authProvider, (_, _) => router.refresh());
|
||||
|
||||
return router;
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import '../models/analytics.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class AnalyticsService {
|
||||
final _client = ApiClient.instance;
|
||||
|
||||
Future<DashboardMetrics> getDashboard() async {
|
||||
final res = await _client.get('/analytics/dashboard');
|
||||
return DashboardMetrics.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
import '../storage/token_storage.dart';
|
||||
|
||||
class ApiClient {
|
||||
static final ApiClient _instance = ApiClient._();
|
||||
late final Dio _dio;
|
||||
|
||||
ApiClient._() {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: AppConstants.baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
));
|
||||
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
final token = await TokenStorage.getToken();
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
onResponse: (response, handler) {
|
||||
// Every successful response from this API is wrapped: { data: <payload> }
|
||||
// Unwrap it so services always receive the raw payload.
|
||||
final body = response.data;
|
||||
if (body is Map<String, dynamic> &&
|
||||
body.length == 1 &&
|
||||
body.containsKey('data')) {
|
||||
response.data = body['data'];
|
||||
}
|
||||
handler.next(response);
|
||||
},
|
||||
onError: (e, handler) {
|
||||
handler.next(e);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
static ApiClient get instance => _instance;
|
||||
|
||||
// baseUrl is dynamic (platform-aware), so recreate when it may have changed.
|
||||
// In practice this is called once and cached via the singleton.
|
||||
Dio get dio => _dio;
|
||||
|
||||
Future<Response> get(String path, {Map<String, dynamic>? params}) =>
|
||||
_dio.get(path, queryParameters: params);
|
||||
|
||||
Future<Response> post(String path, {dynamic data}) =>
|
||||
_dio.post(path, data: data);
|
||||
|
||||
Future<Response> patch(String path, {dynamic data}) =>
|
||||
_dio.patch(path, data: data);
|
||||
|
||||
Future<Response> delete(String path) => _dio.delete(path);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import '../models/auth_models.dart';
|
||||
import '../storage/token_storage.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class AuthService {
|
||||
final _client = ApiClient.instance;
|
||||
|
||||
// Response after envelope unwrap: { token, employee: { id, firstName, ... } }
|
||||
Future<({String token, Employee employee})> loginEmployee({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
final res = await _client.post('/auth/employee/login', data: {
|
||||
'email': email,
|
||||
'password': password,
|
||||
});
|
||||
final data = res.data as Map<String, dynamic>;
|
||||
final token = data['token'] as String;
|
||||
final employee =
|
||||
Employee.fromJson(data['employee'] as Map<String, dynamic>);
|
||||
return (token: token, employee: employee);
|
||||
}
|
||||
|
||||
// Response after envelope unwrap: { employee: { id, firstName, ... } }
|
||||
Future<Employee> getMe() async {
|
||||
final res = await _client.get('/auth/employee/me');
|
||||
final data = res.data as Map<String, dynamic>;
|
||||
return Employee.fromJson(data['employee'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<Renter> getRenterMe() async {
|
||||
final res = await _client.get('/auth/renter/me');
|
||||
final data = res.data as Map<String, dynamic>;
|
||||
return Renter.fromJson(data);
|
||||
}
|
||||
|
||||
Future<void> logout() => TokenStorage.clear();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import '../models/customer.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class CustomerService {
|
||||
final _client = ApiClient.instance;
|
||||
|
||||
Future<CustomerListResponse> getCustomers({
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
String? search,
|
||||
}) async {
|
||||
final res = await _client.get('/customers', params: {
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
if (search != null && search.isNotEmpty) 'search': search,
|
||||
});
|
||||
return CustomerListResponse.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<Customer> getCustomer(String id) async {
|
||||
final res = await _client.get('/customers/$id');
|
||||
return Customer.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<Customer> createCustomer(Map<String, dynamic> data) async {
|
||||
final res = await _client.post('/customers', data: data);
|
||||
return Customer.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import '../models/marketplace.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class MarketplaceService {
|
||||
final _client = ApiClient.instance;
|
||||
|
||||
// After envelope unwrap: [ "City1", "City2", ... ]
|
||||
Future<List<String>> getCities() async {
|
||||
final res = await _client.get('/marketplace/cities');
|
||||
final data = res.data;
|
||||
if (data is List) {
|
||||
return data.whereType<String>().toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// After envelope unwrap: [ { id, title, ... }, ... ]
|
||||
Future<List<MarketplaceOffer>> getOffers() async {
|
||||
final res = await _client.get('/marketplace/offers');
|
||||
final data = res.data;
|
||||
if (data is List) {
|
||||
return data
|
||||
.map((e) => MarketplaceOffer.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// After envelope unwrap: [ { id, make, model, ... }, ... ]
|
||||
Future<MarketplaceSearchResult> search({
|
||||
String? city,
|
||||
DateTime? startDate,
|
||||
DateTime? endDate,
|
||||
String? category,
|
||||
String? transmission,
|
||||
String? make,
|
||||
int? maxPriceCents,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
}) async {
|
||||
final res = await _client.get('/marketplace/search', params: {
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
if (city != null && city.isNotEmpty) 'city': city,
|
||||
if (startDate != null)
|
||||
'startDate': '${startDate.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
||||
if (endDate != null)
|
||||
'endDate': '${endDate.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
||||
if (category != null) 'category': category,
|
||||
if (transmission != null) 'transmission': transmission,
|
||||
if (make != null && make.isNotEmpty) 'make': make,
|
||||
if (maxPriceCents != null) 'maxPrice': maxPriceCents,
|
||||
});
|
||||
|
||||
final data = res.data;
|
||||
if (data is List) {
|
||||
final vehicles = data
|
||||
.map((e) => MarketplaceVehicle.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return MarketplaceSearchResult(
|
||||
data: vehicles,
|
||||
total: vehicles.length,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
}
|
||||
// Fallback: paginated object shape { data: [...], total: N }
|
||||
return MarketplaceSearchResult.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<String> createReservation({
|
||||
required String vehicleId,
|
||||
required String companySlug,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required String email,
|
||||
String? phone,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? notes,
|
||||
String language = 'en',
|
||||
}) async {
|
||||
final res = await _client.post('/marketplace/reservations', data: {
|
||||
'vehicleId': vehicleId,
|
||||
'companySlug': companySlug,
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'email': email,
|
||||
if (phone != null && phone.isNotEmpty) 'phone': phone,
|
||||
'startDate': '${startDate.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
||||
'endDate': '${endDate.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
||||
if (notes != null && notes.isNotEmpty) 'notes': notes,
|
||||
'language': language,
|
||||
});
|
||||
final data = res.data as Map<String, dynamic>;
|
||||
return data['reservationId'] as String;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import '../models/reservation.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class ReservationService {
|
||||
final _client = ApiClient.instance;
|
||||
|
||||
Future<ReservationListResponse> getReservations({
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
String? status,
|
||||
String? search,
|
||||
}) async {
|
||||
final res = await _client.get('/reservations', params: {
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
if (status != null) 'status': status,
|
||||
if (search != null && search.isNotEmpty) 'search': search,
|
||||
});
|
||||
return ReservationListResponse.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<Reservation> getReservation(String id) async {
|
||||
final res = await _client.get('/reservations/$id');
|
||||
return Reservation.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<Reservation> createReservation(Map<String, dynamic> data) async {
|
||||
final res = await _client.post('/reservations', data: data);
|
||||
return Reservation.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> confirmReservation(String id) async {
|
||||
await _client.post('/reservations/$id/confirm');
|
||||
}
|
||||
|
||||
Future<void> cancelReservation(String id, String reason) async {
|
||||
await _client.post('/reservations/$id/cancel', data: {'reason': reason});
|
||||
}
|
||||
|
||||
Future<void> checkin(String id, int mileage) async {
|
||||
await _client.post('/reservations/$id/checkin', data: {'mileage': mileage});
|
||||
}
|
||||
|
||||
Future<void> checkout(String id, int mileage) async {
|
||||
await _client.post('/reservations/$id/checkout', data: {'mileage': mileage});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import '../models/vehicle.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class VehicleService {
|
||||
final _client = ApiClient.instance;
|
||||
|
||||
Future<VehicleListResponse> getVehicles({
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
String? status,
|
||||
String? search,
|
||||
}) async {
|
||||
final res = await _client.get('/vehicles', params: {
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
if (status != null) 'status': status,
|
||||
if (search != null && search.isNotEmpty) 'search': search,
|
||||
});
|
||||
return VehicleListResponse.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<Vehicle> getVehicle(String id) async {
|
||||
final res = await _client.get('/vehicles/$id');
|
||||
return Vehicle.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<Vehicle> createVehicle(Map<String, dynamic> data) async {
|
||||
final res = await _client.post('/vehicles', data: data);
|
||||
return Vehicle.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<Vehicle> updateVehicle(String id, Map<String, dynamic> data) async {
|
||||
final res = await _client.patch('/vehicles/$id', data: data);
|
||||
return Vehicle.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> togglePublish(String id, bool publish) async {
|
||||
await _client.patch('/vehicles/$id/publish', data: {'isPublished': publish});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
|
||||
class TokenStorage {
|
||||
static const _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
static Future<void> saveToken(String token) =>
|
||||
_storage.write(key: AppConstants.tokenKey, value: token);
|
||||
|
||||
static Future<String?> getToken() =>
|
||||
_storage.read(key: AppConstants.tokenKey);
|
||||
|
||||
static Future<void> saveUserType(String type) =>
|
||||
_storage.write(key: AppConstants.userTypeKey, value: type);
|
||||
|
||||
static Future<String?> getUserType() =>
|
||||
_storage.read(key: AppConstants.userTypeKey);
|
||||
|
||||
static Future<void> clear() => _storage.deleteAll();
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const _blue = Color(0xFF1A56DB);
|
||||
const _orange = Color(0xFFFF6B00);
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData get light => ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: _blue,
|
||||
onPrimary: Colors.white,
|
||||
secondary: _orange,
|
||||
onSecondary: Colors.white,
|
||||
surface: Color(0xFFF4F5F7),
|
||||
surfaceContainerHigh: Color(0xFFFFFFFF),
|
||||
surfaceContainerHighest: Color(0xFFEFF0F2),
|
||||
onSurface: Color(0xFF111928),
|
||||
onSurfaceVariant: Color(0xFF6B7280),
|
||||
outline: Color(0xFFD1D5DB),
|
||||
outlineVariant: Color(0xFFE5E7EB),
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFFF4F5F7),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Color(0xFF111928),
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: const BorderSide(color: Color(0xFFE5E7EB)),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D5DB)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D5DB)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: _blue, width: 2),
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _blue,
|
||||
foregroundColor: Colors.white,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
textButtonTheme:
|
||||
TextButtonThemeData(style: TextButton.styleFrom(foregroundColor: _blue)),
|
||||
chipTheme: ChipThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: Colors.white,
|
||||
indicatorColor: _orange.withValues(alpha: 0.12),
|
||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const IconThemeData(color: _orange);
|
||||
}
|
||||
return const IconThemeData(color: Color(0xFF9CA3AF));
|
||||
}),
|
||||
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const TextStyle(
|
||||
color: _orange, fontWeight: FontWeight.w600, fontSize: 12);
|
||||
}
|
||||
return const TextStyle(color: Color(0xFF9CA3AF), fontSize: 12);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
static ThemeData get dark => ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _blue,
|
||||
onPrimary: Colors.white,
|
||||
secondary: _orange,
|
||||
onSecondary: Colors.white,
|
||||
surface: Color(0xFF1C1C28),
|
||||
surfaceContainerHigh: Color(0xFF28293A),
|
||||
surfaceContainerHighest: Color(0xFF32334A),
|
||||
onSurface: Colors.white,
|
||||
onSurfaceVariant: Color(0xFF9CA3AF),
|
||||
outline: Color(0xFF4B4C63),
|
||||
outlineVariant: Color(0xFF3A3B52),
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFF1C1C28),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Color(0xFF1C1C28),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: const Color(0xFF28293A),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: const BorderSide(color: Color(0xFF3A3B52)),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF28293A),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: Color(0xFF3A3B52)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: Color(0xFF3A3B52)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: _blue, width: 2),
|
||||
),
|
||||
hintStyle: const TextStyle(color: Color(0xFF9CA3AF)),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _blue,
|
||||
foregroundColor: Colors.white,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
textButtonTheme:
|
||||
TextButtonThemeData(style: TextButton.styleFrom(foregroundColor: _blue)),
|
||||
chipTheme: ChipThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: const Color(0xFF1E1F2E),
|
||||
indicatorColor: _orange.withValues(alpha: 0.18),
|
||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const IconThemeData(color: _orange);
|
||||
}
|
||||
return const IconThemeData(color: Color(0xFF9CA3AF));
|
||||
}),
|
||||
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const TextStyle(
|
||||
color: _orange, fontWeight: FontWeight.w600, fontSize: 12);
|
||||
}
|
||||
return const TextStyle(color: Color(0xFF9CA3AF), fontSize: 12);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
|
||||
class EmployeeLoginScreen extends ConsumerStatefulWidget {
|
||||
const EmployeeLoginScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<EmployeeLoginScreen> createState() =>
|
||||
_EmployeeLoginScreenState();
|
||||
}
|
||||
|
||||
class _EmployeeLoginScreenState extends ConsumerState<EmployeeLoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _login() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await ref.read(authProvider.notifier).loginEmployee(
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Invalid email or password. Please try again.';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Employee Login',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF111928),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Sign in to your company dashboard',
|
||||
style: TextStyle(fontSize: 15, color: Color(0xFF6B7280)),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
if (_error != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFDE8E8),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline,
|
||||
color: Color(0xFFE02424), size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF9B1C1C),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
validator: (v) => (v == null || !v.contains('@'))
|
||||
? 'Enter a valid email'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _login(),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined),
|
||||
onPressed: () => setState(
|
||||
() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
),
|
||||
validator: (v) => (v == null || v.isEmpty)
|
||||
? 'Password is required'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _loading ? null : _login,
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text('Sign In'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../widgets/preferences_bar.dart';
|
||||
|
||||
class LandingScreen extends StatelessWidget {
|
||||
const LandingScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0F2140),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
// Preferences bar (language + theme)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: const [PreferencesBar(onDark: true)],
|
||||
),
|
||||
const Spacer(flex: 2),
|
||||
// Logo
|
||||
Hero(
|
||||
tag: 'app_logo',
|
||||
child: Image.asset(
|
||||
'assets/images/logo.jpeg',
|
||||
width: 160,
|
||||
height: 160,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Text(
|
||||
l10n.appName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
l10n.tagline,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
fontSize: 15,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const Spacer(flex: 3),
|
||||
// Find your car
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => context.go('/client'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFF6B00),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
elevation: 0,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.search_rounded, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
Text(l10n.findYourCar),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () => context.push('/login/employee'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white.withValues(alpha: 0.5),
|
||||
),
|
||||
child: Text(
|
||||
l10n.companySignIn,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class RoleScreen extends StatelessWidget {
|
||||
const RoleScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 40),
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A56DB),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(Icons.directions_car,
|
||||
size: 32, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Welcome to\nCar Rental',
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF111928),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'How would you like to continue?',
|
||||
style: TextStyle(fontSize: 16, color: Color(0xFF6B7280)),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
_RoleCard(
|
||||
icon: Icons.dashboard_rounded,
|
||||
title: 'Company Dashboard',
|
||||
subtitle: 'Manage fleet, reservations & customers',
|
||||
color: const Color(0xFF1A56DB),
|
||||
onTap: () => context.push('/login/employee'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_RoleCard(
|
||||
icon: Icons.search_rounded,
|
||||
title: 'Browse & Reserve',
|
||||
subtitle: 'Find and book a vehicle',
|
||||
color: const Color(0xFF0E9F6E),
|
||||
onTap: () => context.go('/client'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoleCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _RoleCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 26),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
color: Color(0xFF111928),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.arrow_forward_ios_rounded,
|
||||
size: 16, color: color),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
|
||||
class SplashScreen extends ConsumerStatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends ConsumerState<SplashScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(authProvider.notifier).init();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0F2140),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/logo.jpeg',
|
||||
width: 130,
|
||||
height: 130,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'RentalDriveGo',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Find & Reserve Your Car',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 52),
|
||||
const CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFFFF6B00)),
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/marketplace.dart';
|
||||
import '../../../core/services/marketplace_service.dart';
|
||||
|
||||
final marketplaceServiceProvider = Provider((_) => MarketplaceService());
|
||||
|
||||
final citiesProvider = FutureProvider<List<String>>((ref) {
|
||||
return ref.read(marketplaceServiceProvider).getCities();
|
||||
});
|
||||
|
||||
final offersProvider = FutureProvider<List<MarketplaceOffer>>((ref) {
|
||||
return ref.read(marketplaceServiceProvider).getOffers();
|
||||
});
|
||||
|
||||
class SearchParams {
|
||||
final String? city;
|
||||
final DateTime? startDate;
|
||||
final DateTime? endDate;
|
||||
final Set<String>? categories; // null = all; multi-select
|
||||
final String? transmission;
|
||||
final int? maxPriceCents;
|
||||
final int? minPriceCents; // client-side filter only
|
||||
final int page;
|
||||
|
||||
const SearchParams({
|
||||
this.city,
|
||||
this.startDate,
|
||||
this.endDate,
|
||||
this.categories,
|
||||
this.transmission,
|
||||
this.maxPriceCents,
|
||||
this.minPriceCents,
|
||||
this.page = 1,
|
||||
});
|
||||
|
||||
bool get hasDateRange => startDate != null && endDate != null;
|
||||
|
||||
bool get hasFilters =>
|
||||
city != null ||
|
||||
(categories != null && categories!.isNotEmpty) ||
|
||||
transmission != null ||
|
||||
maxPriceCents != null ||
|
||||
minPriceCents != null ||
|
||||
hasDateRange;
|
||||
|
||||
SearchParams copyWith({
|
||||
Object? city = _sentinel,
|
||||
DateTime? startDate,
|
||||
DateTime? endDate,
|
||||
Object? categories = _sentinel,
|
||||
Object? transmission = _sentinel,
|
||||
Object? maxPriceCents = _sentinel,
|
||||
Object? minPriceCents = _sentinel,
|
||||
int? page,
|
||||
bool clearDates = false,
|
||||
}) =>
|
||||
SearchParams(
|
||||
city: city == _sentinel ? this.city : city as String?,
|
||||
startDate: clearDates ? null : (startDate ?? this.startDate),
|
||||
endDate: clearDates ? null : (endDate ?? this.endDate),
|
||||
categories: categories == _sentinel
|
||||
? this.categories
|
||||
: categories as Set<String>?,
|
||||
transmission: transmission == _sentinel
|
||||
? this.transmission
|
||||
: transmission as String?,
|
||||
maxPriceCents: maxPriceCents == _sentinel
|
||||
? this.maxPriceCents
|
||||
: maxPriceCents as int?,
|
||||
minPriceCents: minPriceCents == _sentinel
|
||||
? this.minPriceCents
|
||||
: minPriceCents as int?,
|
||||
page: page ?? this.page,
|
||||
);
|
||||
|
||||
static bool _setsEqual(Set<String>? a, Set<String>? b) {
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (a.length != b.length) return false;
|
||||
return a.containsAll(b);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is SearchParams &&
|
||||
other.city == city &&
|
||||
other.startDate == startDate &&
|
||||
other.endDate == endDate &&
|
||||
_setsEqual(other.categories, categories) &&
|
||||
other.transmission == transmission &&
|
||||
other.maxPriceCents == maxPriceCents &&
|
||||
other.minPriceCents == minPriceCents &&
|
||||
other.page == page;
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
final catKey = categories == null
|
||||
? null
|
||||
: (categories!.toList()..sort()).join(',');
|
||||
return Object.hash(city, startDate, endDate, catKey, transmission,
|
||||
maxPriceCents, minPriceCents, page);
|
||||
}
|
||||
}
|
||||
|
||||
const _sentinel = Object();
|
||||
|
||||
final searchParamsProvider = StateProvider<SearchParams>((_) {
|
||||
final now = DateTime.now();
|
||||
return SearchParams(
|
||||
startDate: now,
|
||||
endDate: now.add(const Duration(days: 1)),
|
||||
);
|
||||
});
|
||||
|
||||
final vehicleSearchProvider =
|
||||
FutureProvider.family<MarketplaceSearchResult, SearchParams>(
|
||||
(ref, params) {
|
||||
// Only send a single category to the API; multi-category is filtered client-side
|
||||
final apiCategory =
|
||||
params.categories?.length == 1 ? params.categories!.first : null;
|
||||
|
||||
return ref.read(marketplaceServiceProvider).search(
|
||||
city: params.city,
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
category: apiCategory,
|
||||
transmission: params.transmission,
|
||||
maxPriceCents: params.maxPriceCents,
|
||||
page: params.page,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../core/models/marketplace.dart';
|
||||
import '../providers/client_providers.dart';
|
||||
|
||||
class BookingScreen extends ConsumerStatefulWidget {
|
||||
final String vehicleId;
|
||||
final MarketplaceVehicle? vehicle;
|
||||
|
||||
const BookingScreen({
|
||||
super.key,
|
||||
required this.vehicleId,
|
||||
this.vehicle,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<BookingScreen> createState() => _BookingScreenState();
|
||||
}
|
||||
|
||||
class _BookingScreenState extends ConsumerState<BookingScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _firstNameCtrl = TextEditingController();
|
||||
final _lastNameCtrl = TextEditingController();
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _phoneCtrl = TextEditingController();
|
||||
final _notesCtrl = TextEditingController();
|
||||
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstNameCtrl.dispose();
|
||||
_lastNameCtrl.dispose();
|
||||
_emailCtrl.dispose();
|
||||
_phoneCtrl.dispose();
|
||||
_notesCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final params = ref.read(searchParamsProvider);
|
||||
if (!params.hasDateRange) {
|
||||
setState(() => _error = 'Please select dates before booking.');
|
||||
return;
|
||||
}
|
||||
|
||||
final vehicle = widget.vehicle;
|
||||
if (vehicle == null) {
|
||||
setState(() => _error = 'Vehicle information missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final reservationId =
|
||||
await ref.read(marketplaceServiceProvider).createReservation(
|
||||
vehicleId: vehicle.id,
|
||||
companySlug: vehicle.companySlug,
|
||||
firstName: _firstNameCtrl.text.trim(),
|
||||
lastName: _lastNameCtrl.text.trim(),
|
||||
email: _emailCtrl.text.trim(),
|
||||
phone: _phoneCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _phoneCtrl.text.trim(),
|
||||
startDate: params.startDate!,
|
||||
endDate: params.endDate!,
|
||||
notes: _notesCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _notesCtrl.text.trim(),
|
||||
);
|
||||
|
||||
if (mounted) _showSuccess(reservationId, vehicle, params);
|
||||
} catch (e) {
|
||||
setState(() =>
|
||||
_error = 'Booking failed. Please check your details and try again.');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showSuccess(
|
||||
String reservationId, MarketplaceVehicle v, SearchParams params) {
|
||||
final fmt = DateFormat('MMM d, yyyy');
|
||||
final days = params.endDate!.difference(params.startDate!).inDays;
|
||||
final total = v.dailyRateCents * days / 100;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFDEF7EC),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.check,
|
||||
color: Color(0xFF057A55), size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Booking Confirmed!',
|
||||
style: TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
v.displayName,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6B7280), fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF9FAFB),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_ConfirmRow(
|
||||
label: 'Dates',
|
||||
value:
|
||||
'${fmt.format(params.startDate!)} → ${fmt.format(params.endDate!)}'),
|
||||
const SizedBox(height: 6),
|
||||
_ConfirmRow(
|
||||
label: 'Duration', value: '$days days'),
|
||||
const SizedBox(height: 6),
|
||||
_ConfirmRow(
|
||||
label: 'Total',
|
||||
value: '\$${total.toStringAsFixed(2)}'),
|
||||
const SizedBox(height: 6),
|
||||
_ConfirmRow(
|
||||
label: 'Reference',
|
||||
value: reservationId.substring(0, 8).toUpperCase(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'A confirmation has been sent\nto your email.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Color(0xFF9CA3AF)),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
context.go('/client');
|
||||
},
|
||||
child: const Text('Back to Search'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final vehicle = widget.vehicle;
|
||||
final params = ref.watch(searchParamsProvider);
|
||||
final hasDates = params.hasDateRange;
|
||||
final days = hasDates
|
||||
? params.endDate!.difference(params.startDate!).inDays
|
||||
: 0;
|
||||
final fmt = DateFormat('MMM d, yyyy');
|
||||
final total = vehicle != null && hasDates
|
||||
? vehicle.dailyRateCents * days / 100
|
||||
: null;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Complete Booking')),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Vehicle summary
|
||||
if (vehicle != null)
|
||||
Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.directions_car,
|
||||
color: Color(0xFF1A56DB), size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(vehicle.displayName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15)),
|
||||
Text(vehicle.company.brand.displayName,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6B7280),
|
||||
fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (total != null)
|
||||
Text(
|
||||
'\$${total.toStringAsFixed(0)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1A56DB),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Date summary
|
||||
if (hasDates)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEBF5FF),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFFBFDBFE)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.calendar_today,
|
||||
color: Color(0xFF1A56DB), size: 16),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'${fmt.format(params.startDate!)} → ${fmt.format(params.endDate!)} · $days day${days == 1 ? '' : 's'}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF1A56DB),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFDF6B2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber,
|
||||
color: Color(0xFFB45309), size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'No dates selected — go back and pick dates',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF92400E), fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (_error != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFDE8E8),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(_error!,
|
||||
style: const TextStyle(color: Color(0xFF9B1C1C))),
|
||||
),
|
||||
],
|
||||
|
||||
// Personal info
|
||||
const _SectionLabel('Your Information'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _firstNameCtrl,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'First Name'),
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Required' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _lastNameCtrl,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Last Name'),
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Required' : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextFormField(
|
||||
controller: _emailCtrl,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
validator: (v) => (v == null || !v.contains('@'))
|
||||
? 'Enter a valid email'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextFormField(
|
||||
controller: _phoneCtrl,
|
||||
keyboardType: TextInputType.phone,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Phone (optional)',
|
||||
prefixIcon: Icon(Icons.phone_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextFormField(
|
||||
controller: _notesCtrl,
|
||||
maxLines: 2,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notes (optional)',
|
||||
prefixIcon: Icon(Icons.notes_outlined),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
ElevatedButton(
|
||||
onPressed: (_loading || !hasDates) ? null : _submit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52)),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(Colors.white)),
|
||||
)
|
||||
: const Text('Confirm Booking',
|
||||
style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
final String text;
|
||||
const _SectionLabel(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(text,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
color: Color(0xFF374151))),
|
||||
);
|
||||
}
|
||||
|
||||
class _ConfirmRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
const _ConfirmRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: Color(0xFF6B7280))),
|
||||
Text(value,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
class ClientShell extends ConsumerWidget {
|
||||
final Widget child;
|
||||
|
||||
const ClientShell({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
int currentIndex = 0;
|
||||
if (location == '/client/bookings') currentIndex = 1;
|
||||
|
||||
return Scaffold(
|
||||
body: child,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: (i) {
|
||||
if (i == 0) {
|
||||
context.go('/client');
|
||||
} else {
|
||||
context.go('/client/bookings');
|
||||
}
|
||||
},
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.search_outlined),
|
||||
selectedIcon: const Icon(Icons.search),
|
||||
label: l10n.browse,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.bookmark_outline),
|
||||
selectedIcon: const Icon(Icons.bookmark),
|
||||
label: l10n.myBookings,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../core/models/marketplace.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../providers/client_providers.dart';
|
||||
|
||||
class ClientVehicleDetailScreen extends ConsumerWidget {
|
||||
final String id;
|
||||
final MarketplaceVehicle? vehicle;
|
||||
|
||||
const ClientVehicleDetailScreen({
|
||||
super.key,
|
||||
required this.id,
|
||||
this.vehicle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final params = ref.watch(searchParamsProvider);
|
||||
// Use vehicle passed via extra; no separate fetch needed
|
||||
final v = vehicle;
|
||||
if (v == null) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
return _DetailView(vehicle: v, searchParams: params);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailView extends StatelessWidget {
|
||||
final MarketplaceVehicle vehicle;
|
||||
final SearchParams searchParams;
|
||||
|
||||
const _DetailView({required this.vehicle, required this.searchParams});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasDates = searchParams.hasDateRange;
|
||||
final days = hasDates
|
||||
? searchParams.endDate!.difference(searchParams.startDate!).inDays
|
||||
: null;
|
||||
final fmt = DateFormat('MMM d, yyyy');
|
||||
final baseUrl = AppConstants.baseUrl.replaceAll('/api/v1', '');
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 280,
|
||||
pinned: true,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: vehicle.primaryPhoto != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: vehicle.primaryPhoto!.startsWith('http')
|
||||
? vehicle.primaryPhoto!
|
||||
: '$baseUrl${vehicle.primaryPhoto!}',
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, _, _) => _placeholder(),
|
||||
)
|
||||
: _placeholder(),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Company
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.business_outlined,
|
||||
size: 14, color: Color(0xFF9CA3AF)),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
vehicle.company.brand.displayName,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6B7280), fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// Name
|
||||
Text(
|
||||
vehicle.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF111928),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Specs grid
|
||||
_SpecsGrid(vehicle: vehicle),
|
||||
const SizedBox(height: 20),
|
||||
// Features
|
||||
if (vehicle.features.isNotEmpty) ...[
|
||||
const Text('Features',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
color: Color(0xFF374151))),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: vehicle.features
|
||||
.map((f) => Chip(
|
||||
label: Text(f,
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
backgroundColor:
|
||||
const Color(0xFFF3F4F6),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
// Pricing card
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F7FF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFBFDBFE)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('Daily rate',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF6B7280),
|
||||
fontSize: 13)),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'\$${vehicle.dailyRate.toStringAsFixed(2)}/day',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1A56DB),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasDates && days != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'${fmt.format(searchParams.startDate!)} → ${fmt.format(searchParams.endDate!)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF6B7280)),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'$days day${days == 1 ? '' : 's'}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6B7280),
|
||||
fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Text('Estimated total',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'\$${(vehicle.dailyRateCents * days / 100).toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: Color(0xFF111928),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 100),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52)),
|
||||
onPressed: vehicle.availability
|
||||
? () => context.push('/client/book/${vehicle.id}',
|
||||
extra: vehicle)
|
||||
: null,
|
||||
child: Text(vehicle.availability
|
||||
? 'Reserve This Car'
|
||||
: 'Not Available'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholder() => Container(
|
||||
color: const Color(0xFFF3F4F6),
|
||||
child: const Center(
|
||||
child: Icon(Icons.directions_car,
|
||||
size: 72, color: Color(0xFFD1D5DB)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SpecsGrid extends StatelessWidget {
|
||||
final MarketplaceVehicle vehicle;
|
||||
|
||||
const _SpecsGrid({required this.vehicle});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = [
|
||||
(Icons.category_outlined, 'Category',
|
||||
vehicle.category[0] +
|
||||
vehicle.category.substring(1).toLowerCase()),
|
||||
(Icons.people_outline, 'Seats', '${vehicle.seats}'),
|
||||
(Icons.settings_outlined, 'Transmission',
|
||||
vehicle.transmission[0] +
|
||||
vehicle.transmission.substring(1).toLowerCase()),
|
||||
(Icons.local_gas_station_outlined, 'Fuel',
|
||||
vehicle.fuelType[0] +
|
||||
vehicle.fuelType.substring(1).toLowerCase()),
|
||||
if (vehicle.color != null)
|
||||
(Icons.palette_outlined, 'Color', vehicle.color!),
|
||||
if (vehicle.mileage != null)
|
||||
(Icons.speed_outlined, 'Mileage',
|
||||
'${vehicle.mileage} km'),
|
||||
];
|
||||
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisExtent: 64,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 10,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) => Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF9FAFB),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(items[i].$1, size: 16, color: const Color(0xFF6B7280)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(items[i].$2,
|
||||
style: const TextStyle(
|
||||
fontSize: 10, color: Color(0xFF9CA3AF))),
|
||||
Text(items[i].$3,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF111928)),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
|
||||
class MyBookingsScreen extends ConsumerWidget {
|
||||
const MyBookingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final auth = ref.watch(authProvider);
|
||||
|
||||
if (auth.status != AuthStatus.authenticated) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('My Bookings')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.bookmark_outline,
|
||||
size: 64, color: Color(0xFFD1D5DB)),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Sign in to view your bookings',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF111928),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Create an account or log in to manage your reservations.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Color(0xFF6B7280)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.push('/role'),
|
||||
child: const Text('Sign In'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('My Bookings')),
|
||||
body: const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.event_available, size: 64, color: Color(0xFFD1D5DB)),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'No bookings yet',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/marketplace.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
|
||||
const _orange = Color(0xFFFF6B00);
|
||||
|
||||
class MarketplaceVehicleCard extends StatelessWidget {
|
||||
final MarketplaceVehicle vehicle;
|
||||
final DateTime? selectedStart;
|
||||
final DateTime? selectedEnd;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const MarketplaceVehicleCard({
|
||||
super.key,
|
||||
required this.vehicle,
|
||||
this.selectedStart,
|
||||
this.selectedEnd,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final hasDates = selectedStart != null && selectedEnd != null;
|
||||
final days =
|
||||
hasDates ? selectedEnd!.difference(selectedStart!).inDays : null;
|
||||
final totalCents = days != null ? vehicle.dailyRateCents * days : null;
|
||||
|
||||
return Material(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
splashColor: _orange.withValues(alpha: 0.08),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
_VehicleImage(vehicle: vehicle, cs: cs),
|
||||
if (!vehicle.availability)
|
||||
Positioned(
|
||||
top: 10,
|
||||
left: 10,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'Unavailable',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 10,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
vehicle.category[0] +
|
||||
vehicle.category.substring(1).toLowerCase(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.business_outlined,
|
||||
size: 12, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
vehicle.company.brand.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
vehicle.displayName,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 16,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
_Spec(
|
||||
icon: Icons.people_outline,
|
||||
label: '${vehicle.seats} seats',
|
||||
cs: cs),
|
||||
const SizedBox(width: 14),
|
||||
_Spec(
|
||||
icon: Icons.settings_outlined,
|
||||
label: vehicle.transmission[0] +
|
||||
vehicle.transmission
|
||||
.substring(1)
|
||||
.toLowerCase(),
|
||||
cs: cs),
|
||||
const SizedBox(width: 14),
|
||||
_Spec(
|
||||
icon: Icons.local_gas_station_outlined,
|
||||
label: vehicle.fuelType[0] +
|
||||
vehicle.fuelType.substring(1).toLowerCase(),
|
||||
cs: cs),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Divider(height: 1, color: cs.outlineVariant),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'\$${vehicle.dailyRate.toStringAsFixed(0)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _orange,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'/day',
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: cs.onSurfaceVariant),
|
||||
),
|
||||
if (totalCents != null) ...[
|
||||
const Spacer(),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'$days day${days == 1 ? '' : 's'}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: cs.onSurfaceVariant),
|
||||
),
|
||||
Text(
|
||||
'\$${(totalCents / 100).toStringAsFixed(0)} total',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 14,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VehicleImage extends StatelessWidget {
|
||||
final MarketplaceVehicle vehicle;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _VehicleImage({required this.vehicle, required this.cs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final photo = vehicle.primaryPhoto;
|
||||
if (photo == null) return _placeholder();
|
||||
|
||||
final url = _resolveUrl(photo);
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
height: 180,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, _) => _placeholder(),
|
||||
errorWidget: (_, _, _) => _placeholder(),
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveUrl(String photo) {
|
||||
if (!photo.startsWith('http')) {
|
||||
return '${AppConstants.baseUrl.replaceAll('/api/v1', '')}$photo';
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
return photo.replaceFirst('localhost', '10.0.2.2');
|
||||
}
|
||||
return photo;
|
||||
}
|
||||
|
||||
Widget _placeholder() => Container(
|
||||
height: 180,
|
||||
color: const Color(0xFF1C1C28),
|
||||
child: Center(
|
||||
child: Icon(Icons.directions_car,
|
||||
size: 56, color: cs.outlineVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Spec extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _Spec({required this.icon, required this.label, required this.cs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 13, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 3),
|
||||
Text(label,
|
||||
style:
|
||||
TextStyle(fontSize: 12, color: cs.onSurfaceVariant)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/marketplace.dart';
|
||||
|
||||
class OfferBanner extends StatelessWidget {
|
||||
final MarketplaceOffer offer;
|
||||
|
||||
const OfferBanner({super.key, required this.offer});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final daysLeft =
|
||||
offer.validUntil.difference(DateTime.now()).inDays;
|
||||
|
||||
return Container(
|
||||
width: 260,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
offer.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
offer.discountLabel,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
offer.company.brand.displayName,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
daysLeft > 0 ? '$daysLeft days left' : 'Ends today',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/analytics.dart';
|
||||
import '../../../core/models/vehicle.dart';
|
||||
import '../../../core/models/reservation.dart';
|
||||
import '../../../core/models/customer.dart';
|
||||
import '../../../core/services/analytics_service.dart';
|
||||
import '../../../core/services/vehicle_service.dart';
|
||||
import '../../../core/services/reservation_service.dart';
|
||||
import '../../../core/services/customer_service.dart';
|
||||
|
||||
final analyticsServiceProvider = Provider((_) => AnalyticsService());
|
||||
final vehicleServiceProvider = Provider((_) => VehicleService());
|
||||
final reservationServiceProvider = Provider((_) => ReservationService());
|
||||
final customerServiceProvider = Provider((_) => CustomerService());
|
||||
|
||||
final dashboardMetricsProvider = FutureProvider<DashboardMetrics>((ref) async {
|
||||
return ref.read(analyticsServiceProvider).getDashboard();
|
||||
});
|
||||
|
||||
final vehiclesProvider = FutureProvider.family<VehicleListResponse, Map<String, dynamic>>(
|
||||
(ref, params) async {
|
||||
return ref.read(vehicleServiceProvider).getVehicles(
|
||||
page: params['page'] as int? ?? 1,
|
||||
pageSize: params['pageSize'] as int? ?? 20,
|
||||
status: params['status'] as String?,
|
||||
search: params['search'] as String?,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final vehicleDetailProvider = FutureProvider.family<Vehicle, String>((ref, id) {
|
||||
return ref.read(vehicleServiceProvider).getVehicle(id);
|
||||
});
|
||||
|
||||
final reservationsProvider =
|
||||
FutureProvider.family<ReservationListResponse, Map<String, dynamic>>(
|
||||
(ref, params) async {
|
||||
return ref.read(reservationServiceProvider).getReservations(
|
||||
page: params['page'] as int? ?? 1,
|
||||
pageSize: params['pageSize'] as int? ?? 20,
|
||||
status: params['status'] as String?,
|
||||
search: params['search'] as String?,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final reservationDetailProvider =
|
||||
FutureProvider.family<Reservation, String>((ref, id) {
|
||||
return ref.read(reservationServiceProvider).getReservation(id);
|
||||
});
|
||||
|
||||
final customersProvider =
|
||||
FutureProvider.family<CustomerListResponse, Map<String, dynamic>>(
|
||||
(ref, params) async {
|
||||
return ref.read(customerServiceProvider).getCustomers(
|
||||
page: params['page'] as int? ?? 1,
|
||||
pageSize: params['pageSize'] as int? ?? 20,
|
||||
search: params['search'] as String?,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final customerDetailProvider =
|
||||
FutureProvider.family<Customer, String>((ref, id) {
|
||||
return ref.read(customerServiceProvider).getCustomer(id);
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/dashboard_providers.dart';
|
||||
import '../../../widgets/status_badge.dart';
|
||||
import '../../../widgets/error_view.dart';
|
||||
|
||||
class CustomerDetailScreen extends ConsumerWidget {
|
||||
final String id;
|
||||
|
||||
const CustomerDetailScreen({super.key, required this.id});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(customerDetailProvider(id));
|
||||
|
||||
return async.when(
|
||||
loading: () =>
|
||||
const Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||
error: (e, _) => Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: ErrorView(
|
||||
message: 'Could not load customer',
|
||||
onRetry: () => ref.invalidate(customerDetailProvider(id)),
|
||||
),
|
||||
),
|
||||
data: (customer) {
|
||||
final fmt = DateFormat('MMM d, yyyy');
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(customer.fullName)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundColor: customer.isFlagged
|
||||
? const Color(0xFFFDE8E8)
|
||||
: const Color(0xFFE1EFFE),
|
||||
child: Text(
|
||||
customer.firstName.substring(0, 1).toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
color: customer.isFlagged
|
||||
? const Color(0xFFE02424)
|
||||
: const Color(0xFF1A56DB),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
customer.fullName,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
StatusBadge(status: customer.licenseStatus),
|
||||
if (customer.isFlagged) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFDE8E8),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.flag,
|
||||
size: 14, color: Color(0xFFE02424)),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
customer.flagReason ?? 'Flagged',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF9B1C1C), fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Contact Information',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF6B7280),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (customer.email != null)
|
||||
_InfoRow(
|
||||
icon: Icons.email_outlined,
|
||||
value: customer.email!),
|
||||
if (customer.phone != null)
|
||||
_InfoRow(
|
||||
icon: Icons.phone_outlined,
|
||||
value: customer.phone!),
|
||||
if (customer.nationality != null)
|
||||
_InfoRow(
|
||||
icon: Icons.flag_outlined,
|
||||
value: customer.nationality!),
|
||||
if (customer.dateOfBirth != null)
|
||||
_InfoRow(
|
||||
icon: Icons.cake_outlined,
|
||||
value: fmt.format(customer.dateOfBirth!),
|
||||
),
|
||||
_InfoRow(
|
||||
icon: Icons.calendar_today_outlined,
|
||||
value: 'Joined ${fmt.format(customer.createdAt)}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String value;
|
||||
|
||||
const _InfoRow({required this.icon, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: const Color(0xFF6B7280)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(value,
|
||||
style: const TextStyle(fontSize: 14)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../providers/dashboard_providers.dart';
|
||||
import '../../../widgets/loading_list.dart';
|
||||
import '../../../widgets/error_view.dart';
|
||||
import '../../../widgets/status_badge.dart';
|
||||
|
||||
class CustomersScreen extends ConsumerStatefulWidget {
|
||||
const CustomersScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CustomersScreen> createState() => _CustomersScreenState();
|
||||
}
|
||||
|
||||
class _CustomersScreenState extends ConsumerState<CustomersScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
String _search = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Map<String, dynamic> get _params => {
|
||||
'page': 1,
|
||||
'pageSize': 50,
|
||||
if (_search.isNotEmpty) 'search': _search,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final customersAsync = ref.watch(customersProvider(_params));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Customers'),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(60),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search customers...',
|
||||
prefixIcon: Icon(Icons.search, size: 20),
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
onChanged: (v) => setState(() => _search = v),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: customersAsync.when(
|
||||
loading: () => const LoadingList(itemHeight: 80),
|
||||
error: (e, _) => ErrorView(
|
||||
message: 'Failed to load customers',
|
||||
onRetry: () => ref.invalidate(customersProvider(_params)),
|
||||
),
|
||||
data: (res) => res.data.isEmpty
|
||||
? const Center(
|
||||
child: Text('No customers found',
|
||||
style: TextStyle(color: Color(0xFF9CA3AF))))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
ref.invalidate(customersProvider(_params)),
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: res.data.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (_, i) {
|
||||
final c = res.data[i];
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
context.push('/dashboard/customers/${c.id}'),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: c.isFlagged
|
||||
? const Color(0xFFFDE8E8)
|
||||
: const Color(0xFFE1EFFE),
|
||||
radius: 22,
|
||||
child: Text(
|
||||
c.firstName.substring(0, 1).toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: c.isFlagged
|
||||
? const Color(0xFFE02424)
|
||||
: const Color(0xFF1A56DB),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
c.fullName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (c.isFlagged) ...[
|
||||
const SizedBox(width: 6),
|
||||
const Icon(Icons.flag,
|
||||
size: 14,
|
||||
color: Color(0xFFE02424)),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (c.email != null)
|
||||
Text(
|
||||
c.email!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
StatusBadge(status: c.licenseStatus),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
|
||||
class DashboardShell extends ConsumerWidget {
|
||||
final Widget child;
|
||||
|
||||
const DashboardShell({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final employee = ref.watch(authProvider).employee;
|
||||
|
||||
int currentIndex = 0;
|
||||
if (location.startsWith('/dashboard/vehicles')) currentIndex = 1;
|
||||
if (location.startsWith('/dashboard/reservations')) currentIndex = 2;
|
||||
if (location.startsWith('/dashboard/customers')) currentIndex = 3;
|
||||
|
||||
return Scaffold(
|
||||
drawer: _Drawer(employee: employee, ref: ref),
|
||||
body: child,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: (i) {
|
||||
switch (i) {
|
||||
case 0:
|
||||
context.go('/dashboard');
|
||||
case 1:
|
||||
context.go('/dashboard/vehicles');
|
||||
case 2:
|
||||
context.go('/dashboard/reservations');
|
||||
case 3:
|
||||
context.go('/dashboard/customers');
|
||||
}
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: 'Dashboard',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.directions_car_outlined),
|
||||
selectedIcon: Icon(Icons.directions_car),
|
||||
label: 'Vehicles',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.event_note_outlined),
|
||||
selectedIcon: Icon(Icons.event_note),
|
||||
label: 'Reservations',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.people_outlined),
|
||||
selectedIcon: Icon(Icons.people),
|
||||
label: 'Customers',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Drawer extends StatelessWidget {
|
||||
final dynamic employee;
|
||||
final WidgetRef ref;
|
||||
|
||||
const _Drawer({required this.employee, required this.ref});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Drawer(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: const Color(0xFF1A56DB),
|
||||
radius: 24,
|
||||
child: Text(
|
||||
employee?.firstName.substring(0, 1).toUpperCase() ?? 'U',
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
employee?.fullName ?? 'Employee',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
employee?.role ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Color(0xFF6B7280)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const Spacer(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.logout, color: Color(0xFFE02424)),
|
||||
title: const Text('Sign Out',
|
||||
style: TextStyle(color: Color(0xFFE02424))),
|
||||
onTap: () async {
|
||||
Navigator.of(context).pop();
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../providers/dashboard_providers.dart';
|
||||
import '../../../widgets/stat_card.dart';
|
||||
import '../../../widgets/error_view.dart';
|
||||
import '../../../widgets/reservation_card.dart';
|
||||
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final employee = ref.watch(authProvider).employee;
|
||||
final metricsAsync = ref.watch(dashboardMetricsProvider);
|
||||
final recentAsync = ref.watch(
|
||||
reservationsProvider({'page': 1, 'pageSize': 5}),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Hello, ${employee?.firstName ?? 'there'}',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
DateFormat('EEEE, MMMM d').format(DateTime.now()),
|
||||
style:
|
||||
const TextStyle(fontSize: 12, color: Color(0xFF6B7280)),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Builder(
|
||||
builder: (ctx) => IconButton(
|
||||
icon: const Icon(Icons.menu),
|
||||
onPressed: () => Scaffold.of(ctx).openDrawer(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(dashboardMetricsProvider);
|
||||
ref.invalidate(reservationsProvider);
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
metricsAsync.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => ErrorView(
|
||||
message: 'Could not load metrics',
|
||||
onRetry: () => ref.invalidate(dashboardMetricsProvider),
|
||||
),
|
||||
data: (metrics) => Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: StatCard(
|
||||
title: 'Total Vehicles',
|
||||
value: '${metrics.totalVehicles}',
|
||||
icon: Icons.directions_car,
|
||||
color: const Color(0xFF1A56DB),
|
||||
subtitle: '${metrics.availableVehicles} available',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: StatCard(
|
||||
title: 'Active Rentals',
|
||||
value: '${metrics.activeReservations}',
|
||||
icon: Icons.event_available,
|
||||
color: const Color(0xFF0E9F6E),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: StatCard(
|
||||
title: 'Revenue',
|
||||
value:
|
||||
'\$${NumberFormat.compact().format(metrics.totalRevenue)}',
|
||||
icon: Icons.attach_money,
|
||||
color: const Color(0xFF5521B5),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: StatCard(
|
||||
title: 'Customers',
|
||||
value: '${metrics.totalCustomers}',
|
||||
icon: Icons.people,
|
||||
color: const Color(0xFFB45309),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Fleet Occupancy',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF374151),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(
|
||||
value: metrics.occupancyRate / 100,
|
||||
backgroundColor: const Color(0xFFE5E7EB),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
Color(0xFF1A56DB)),
|
||||
minHeight: 8,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${metrics.occupancyRate.toStringAsFixed(1)}% occupied',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Recent Reservations',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF111928),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/dashboard/reservations'),
|
||||
child: const Text('View all'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
recentAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => const ErrorView(
|
||||
message: 'Could not load recent reservations'),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/dashboard_providers.dart';
|
||||
import '../../../widgets/status_badge.dart';
|
||||
import '../../../widgets/error_view.dart';
|
||||
|
||||
class ReservationDetailScreen extends ConsumerWidget {
|
||||
final String id;
|
||||
|
||||
const ReservationDetailScreen({super.key, required this.id});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(reservationDetailProvider(id));
|
||||
|
||||
return async.when(
|
||||
loading: () =>
|
||||
const Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||
error: (e, _) => Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: ErrorView(
|
||||
message: 'Could not load reservation',
|
||||
onRetry: () => ref.invalidate(reservationDetailProvider(id)),
|
||||
),
|
||||
),
|
||||
data: (res) {
|
||||
final fmt = DateFormat('MMM d, yyyy');
|
||||
final canConfirm = res.status == 'DRAFT';
|
||||
final canCancel =
|
||||
res.status == 'DRAFT' || res.status == 'CONFIRMED';
|
||||
final canCheckin = res.status == 'CONFIRMED';
|
||||
final canCheckout = res.status == 'ACTIVE';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Reservation #${id.substring(0, 8)}'),
|
||||
actions: [
|
||||
StatusBadge(status: res.status),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_Section(
|
||||
title: 'Vehicle',
|
||||
child: res.vehicle != null
|
||||
? _InfoRow(label: 'Vehicle', value: res.vehicle!.displayName)
|
||||
: const Text('N/A'),
|
||||
),
|
||||
_Section(
|
||||
title: 'Customer',
|
||||
child: res.customer != null
|
||||
? Column(
|
||||
children: [
|
||||
_InfoRow(
|
||||
label: 'Name', value: res.customer!.fullName),
|
||||
if (res.customer!.email != null)
|
||||
_InfoRow(
|
||||
label: 'Email', value: res.customer!.email!),
|
||||
if (res.customer!.phone != null)
|
||||
_InfoRow(
|
||||
label: 'Phone', value: res.customer!.phone!),
|
||||
],
|
||||
)
|
||||
: const Text('Walk-in customer'),
|
||||
),
|
||||
_Section(
|
||||
title: 'Dates',
|
||||
child: Column(
|
||||
children: [
|
||||
_InfoRow(label: 'Start', value: fmt.format(res.startDate)),
|
||||
_InfoRow(label: 'End', value: fmt.format(res.endDate)),
|
||||
_InfoRow(label: 'Duration', value: '${res.days} days'),
|
||||
],
|
||||
),
|
||||
),
|
||||
_Section(
|
||||
title: 'Payment',
|
||||
child: Column(
|
||||
children: [
|
||||
_InfoRow(
|
||||
label: 'Total',
|
||||
value: '\$${res.totalAmount.toStringAsFixed(2)}'),
|
||||
Row(
|
||||
children: [
|
||||
const Text('Status: ',
|
||||
style: TextStyle(color: Color(0xFF6B7280))),
|
||||
const SizedBox(width: 4),
|
||||
StatusBadge(status: res.paymentStatus),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (canConfirm)
|
||||
_ActionButton(
|
||||
label: 'Confirm Reservation',
|
||||
color: const Color(0xFF0E9F6E),
|
||||
icon: Icons.check_circle_outline,
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(reservationServiceProvider)
|
||||
.confirmReservation(id);
|
||||
ref.invalidate(reservationDetailProvider(id));
|
||||
},
|
||||
),
|
||||
if (canCheckin)
|
||||
_ActionButton(
|
||||
label: 'Check In',
|
||||
color: const Color(0xFF1A56DB),
|
||||
icon: Icons.login,
|
||||
onTap: () => _showMileageDialog(context, ref, 'checkin'),
|
||||
),
|
||||
if (canCheckout)
|
||||
_ActionButton(
|
||||
label: 'Check Out',
|
||||
color: const Color(0xFF5521B5),
|
||||
icon: Icons.logout,
|
||||
onTap: () => _showMileageDialog(context, ref, 'checkout'),
|
||||
),
|
||||
if (canCancel)
|
||||
_ActionButton(
|
||||
label: 'Cancel Reservation',
|
||||
color: const Color(0xFFE02424),
|
||||
icon: Icons.cancel_outlined,
|
||||
onTap: () => _showCancelDialog(context, ref),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showMileageDialog(
|
||||
BuildContext context, WidgetRef ref, String type) {
|
||||
final ctrl = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: Text(type == 'checkin' ? 'Check In' : 'Check Out'),
|
||||
content: TextField(
|
||||
controller: ctrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Current mileage (km)',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel')),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final mileage = int.tryParse(ctrl.text);
|
||||
if (mileage == null) return;
|
||||
Navigator.pop(context);
|
||||
try {
|
||||
if (type == 'checkin') {
|
||||
await ref
|
||||
.read(reservationServiceProvider)
|
||||
.checkin(id, mileage);
|
||||
} else {
|
||||
await ref
|
||||
.read(reservationServiceProvider)
|
||||
.checkout(id, mileage);
|
||||
}
|
||||
ref.invalidate(reservationDetailProvider(id));
|
||||
} catch (_) {}
|
||||
},
|
||||
child: const Text('Confirm'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCancelDialog(BuildContext context, WidgetRef ref) {
|
||||
final ctrl = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Cancel Reservation'),
|
||||
content: TextField(
|
||||
controller: ctrl,
|
||||
decoration: const InputDecoration(labelText: 'Reason'),
|
||||
maxLines: 3,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Back')),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFE02424)),
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
await ref
|
||||
.read(reservationServiceProvider)
|
||||
.cancelReservation(id, ctrl.text);
|
||||
ref.invalidate(reservationDetailProvider(id));
|
||||
},
|
||||
child: const Text('Cancel Reservation'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
const _Section({required this.title, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF6B7280),
|
||||
fontSize: 12)),
|
||||
const SizedBox(height: 10),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('$label: ',
|
||||
style: const TextStyle(color: Color(0xFF6B7280))),
|
||||
Expanded(
|
||||
child: Text(value,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionButton extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ActionButton({
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
icon: Icon(icon),
|
||||
label: Text(label),
|
||||
onPressed: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../providers/dashboard_providers.dart';
|
||||
import '../../../widgets/reservation_card.dart';
|
||||
import '../../../widgets/loading_list.dart';
|
||||
import '../../../widgets/error_view.dart';
|
||||
|
||||
class ReservationsScreen extends ConsumerStatefulWidget {
|
||||
const ReservationsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ReservationsScreen> createState() => _ReservationsScreenState();
|
||||
}
|
||||
|
||||
class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabs;
|
||||
final _statuses = [null, 'CONFIRMED', 'ACTIVE', 'COMPLETED', 'CANCELLED'];
|
||||
final _labels = ['All', 'Confirmed', 'Active', 'Completed', 'Cancelled'];
|
||||
final _searchController = TextEditingController();
|
||||
String _search = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabs = TabController(length: _statuses.length, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabs.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _params(int tabIndex) => {
|
||||
'page': 1,
|
||||
'pageSize': 50,
|
||||
if (_statuses[tabIndex] != null) 'status': _statuses[tabIndex],
|
||||
if (_search.isNotEmpty) 'search': _search,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Reservations'),
|
||||
bottom: TabBar(
|
||||
controller: _tabs,
|
||||
isScrollable: true,
|
||||
tabs: _labels.map((l) => Tab(text: l)).toList(),
|
||||
onTap: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search reservations...',
|
||||
prefixIcon: Icon(Icons.search, size: 20),
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
onChanged: (v) => setState(() => _search = v),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabs,
|
||||
children: List.generate(
|
||||
_statuses.length,
|
||||
(i) => _ReservationTab(
|
||||
params: _params(i),
|
||||
onTap: (id) =>
|
||||
context.push('/dashboard/reservations/$id'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReservationTab extends ConsumerWidget {
|
||||
final Map<String, dynamic> params;
|
||||
final void Function(String id) onTap;
|
||||
|
||||
const _ReservationTab({required this.params, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(reservationsProvider(params));
|
||||
return async.when(
|
||||
loading: () => const LoadingList(itemHeight: 110),
|
||||
error: (e, _) => ErrorView(
|
||||
message: 'Failed to load reservations',
|
||||
onRetry: () => ref.invalidate(reservationsProvider(params)),
|
||||
),
|
||||
data: (res) => res.data.isEmpty
|
||||
? const Center(
|
||||
child: Text('No reservations found',
|
||||
style: TextStyle(color: Color(0xFF9CA3AF))))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
ref.invalidate(reservationsProvider(params)),
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: res.data.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (_, i) => ReservationCard(
|
||||
reservation: res.data[i],
|
||||
onTap: () => onTap(res.data[i].id),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/dashboard_providers.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../../../widgets/status_badge.dart';
|
||||
import '../../../widgets/error_view.dart';
|
||||
|
||||
class VehicleDetailScreen extends ConsumerWidget {
|
||||
final String id;
|
||||
|
||||
const VehicleDetailScreen({super.key, required this.id});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final vehicleAsync = ref.watch(vehicleDetailProvider(id));
|
||||
|
||||
return vehicleAsync.when(
|
||||
loading: () => const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: ErrorView(
|
||||
message: 'Could not load vehicle',
|
||||
onRetry: () => ref.invalidate(vehicleDetailProvider(id)),
|
||||
),
|
||||
),
|
||||
data: (vehicle) {
|
||||
final baseUrl =
|
||||
AppConstants.baseUrl.replaceAll('/api/v1', '');
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 240,
|
||||
pinned: true,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: vehicle.primaryPhoto != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: vehicle.primaryPhoto!.startsWith('http')
|
||||
? vehicle.primaryPhoto!
|
||||
: '$baseUrl${vehicle.primaryPhoto!}',
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, _, _) => Container(
|
||||
color: const Color(0xFFF3F4F6),
|
||||
child: const Icon(Icons.directions_car,
|
||||
size: 72, color: Color(0xFFD1D5DB)),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: const Color(0xFFF3F4F6),
|
||||
child: const Icon(Icons.directions_car,
|
||||
size: 72, color: Color(0xFFD1D5DB)),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
vehicle.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF111928),
|
||||
),
|
||||
),
|
||||
),
|
||||
StatusBadge(status: vehicle.status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
vehicle.licensePlate,
|
||||
style: const TextStyle(
|
||||
fontSize: 15, color: Color(0xFF6B7280)),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_InfoGrid(vehicle: vehicle),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Pricing',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF111928),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.attach_money,
|
||||
color: Color(0xFF1A56DB)),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'\$${vehicle.dailyRate.toStringAsFixed(2)}/day',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1A56DB),
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'Daily rate',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF6B7280)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Switch(
|
||||
value: vehicle.isPublished,
|
||||
onChanged: (v) async {
|
||||
try {
|
||||
await ref
|
||||
.read(vehicleServiceProvider)
|
||||
.togglePublish(id, v);
|
||||
ref.invalidate(vehicleDetailProvider(id));
|
||||
} catch (_) {}
|
||||
},
|
||||
),
|
||||
const Text(
|
||||
'Published',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Color(0xFF6B7280)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoGrid extends StatelessWidget {
|
||||
final dynamic vehicle;
|
||||
|
||||
const _InfoGrid({required this.vehicle});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = [
|
||||
('Category', vehicle.category),
|
||||
if (vehicle.seats != null) ('Seats', '${vehicle.seats}'),
|
||||
if (vehicle.transmission != null) ('Transmission', vehicle.transmission!),
|
||||
if (vehicle.fuelType != null) ('Fuel', vehicle.fuelType!),
|
||||
if (vehicle.mileage != null)
|
||||
('Mileage', '${vehicle.mileage} km'),
|
||||
];
|
||||
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: items
|
||||
.map((item) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF3F4F6),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.$1,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: Color(0xFF9CA3AF)),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.$2,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF111928),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../providers/dashboard_providers.dart';
|
||||
import '../../../widgets/vehicle_card.dart';
|
||||
import '../../../widgets/loading_list.dart';
|
||||
import '../../../widgets/error_view.dart';
|
||||
|
||||
class VehiclesScreen extends ConsumerStatefulWidget {
|
||||
const VehiclesScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<VehiclesScreen> createState() => _VehiclesScreenState();
|
||||
}
|
||||
|
||||
class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
|
||||
String? _statusFilter;
|
||||
final _searchController = TextEditingController();
|
||||
String _search = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Map<String, dynamic> get _params => {
|
||||
'page': 1,
|
||||
'pageSize': 50,
|
||||
if (_statusFilter != null) 'status': _statusFilter,
|
||||
if (_search.isNotEmpty) 'search': _search,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final vehiclesAsync = ref.watch(vehiclesProvider(_params));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Vehicles'),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(60),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search vehicles...',
|
||||
prefixIcon: Icon(Icons.search, size: 20),
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
onChanged: (v) => setState(() => _search = v),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
PopupMenuButton<String?>(
|
||||
icon: Icon(
|
||||
Icons.filter_list,
|
||||
color: _statusFilter != null
|
||||
? const Color(0xFF1A56DB)
|
||||
: null,
|
||||
),
|
||||
onSelected: (v) => setState(() => _statusFilter = v),
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(value: null, child: Text('All')),
|
||||
const PopupMenuItem(value: 'AVAILABLE', child: Text('Available')),
|
||||
const PopupMenuItem(value: 'RENTED', child: Text('Rented')),
|
||||
const PopupMenuItem(
|
||||
value: 'MAINTENANCE', child: Text('Maintenance')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: vehiclesAsync.when(
|
||||
loading: () => const LoadingList(itemHeight: 240),
|
||||
error: (e, _) => ErrorView(
|
||||
message: 'Failed to load vehicles',
|
||||
onRetry: () => ref.invalidate(vehiclesProvider(_params)),
|
||||
),
|
||||
data: (res) => res.data.isEmpty
|
||||
? const Center(
|
||||
child: Text('No vehicles found',
|
||||
style: TextStyle(color: Color(0xFF9CA3AF))))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
ref.invalidate(vehiclesProvider(_params)),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 1,
|
||||
mainAxisExtent: 240,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: res.data.length,
|
||||
itemBuilder: (_, i) => VehicleCard(
|
||||
vehicle: res.data[i],
|
||||
onTap: () => context
|
||||
.push('/dashboard/vehicles/${res.data[i].id}'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"@@locale": "ar",
|
||||
"appName": "RentalDriveGo",
|
||||
"tagline": "أسهل طريقة للعثور\nوحجز سيارتك المثالية",
|
||||
"findYourCar": "ابحث عن سيارتك",
|
||||
"companySignIn": "تسجيل دخول لوحة تحكم الشركة",
|
||||
"sameDropoff": "إرجاع نفس المكان",
|
||||
"differentDropoff": "إرجاع مكان آخر",
|
||||
"findCarToRent": "ابحث عن سيارة للإيجار",
|
||||
"allCarTypes": "جميع أنواع السيارات",
|
||||
"search": "بحث",
|
||||
"searching": "جارٍ البحث…",
|
||||
"vehiclesFound": "{count, plural, =0{لا توجد سيارات} =1{سيارة واحدة متوفرة} =2{سيارتان متوفرتان} few{{count} سيارات متوفرة} other{{count} سيارة متوفرة}}",
|
||||
"@vehiclesFound": {
|
||||
"placeholders": {
|
||||
"count": { "type": "num" }
|
||||
}
|
||||
},
|
||||
"noVehiclesFound": "لا توجد سيارات",
|
||||
"tryDifferentSearch": "جرّب تواريخ أو مدينة أو نوع مختلف",
|
||||
"couldNotLoadVehicles": "تعذّر تحميل السيارات",
|
||||
"checkConnectionRetry": "تحقق من اتصالك وحاول مجدداً",
|
||||
"retry": "إعادة المحاولة",
|
||||
"myBookings": "حجوزاتي",
|
||||
"browse": "تصفح",
|
||||
"signInToViewBookings": "سجّل الدخول لعرض حجوزاتك",
|
||||
"carType": "نوع السيارة",
|
||||
"selectCity": "اختر المدينة",
|
||||
"allCities": "جميع المدن",
|
||||
"searchCities": "ابحث عن مدينة…",
|
||||
"chooseLanguage": "اللغة",
|
||||
"languageName": "العربية",
|
||||
"perDay": "/يوم",
|
||||
"nDays": "{count, plural, =1{يوم واحد} =2{يومان} few{{count} أيام} other{{count} يوم}}",
|
||||
"@nDays": {
|
||||
"placeholders": {
|
||||
"count": { "type": "num" }
|
||||
}
|
||||
},
|
||||
"total": "الإجمالي",
|
||||
"reserveThisCar": "احجز هذه السيارة",
|
||||
"lightTheme": "فاتح",
|
||||
"darkTheme": "داكن",
|
||||
"settings": "الإعدادات",
|
||||
"anyPrice": "أي سعر",
|
||||
"price": "السعر",
|
||||
"apply": "تطبيق",
|
||||
"selectAll": "تحديد الكل",
|
||||
"clearAll": "مسح الكل",
|
||||
"pickDates": "اختر التواريخ",
|
||||
"pickupLocation": "موقع الاستلام",
|
||||
"dropoffLocation": "موقع الإرجاع"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"@@locale": "en",
|
||||
"appName": "RentalDriveGo",
|
||||
"tagline": "The easiest way to find\nand reserve your perfect car",
|
||||
"findYourCar": "Find your car",
|
||||
"companySignIn": "Company dashboard sign in",
|
||||
"sameDropoff": "Same drop-off",
|
||||
"differentDropoff": "Different drop-off",
|
||||
"findCarToRent": "Find a car to rent",
|
||||
"allCarTypes": "All car types",
|
||||
"search": "Search",
|
||||
"searching": "Searching…",
|
||||
"vehiclesFound": "{count, plural, =0{No vehicles found} =1{1 vehicle found} other{{count} vehicles found}}",
|
||||
"@vehiclesFound": {
|
||||
"placeholders": {
|
||||
"count": { "type": "num" }
|
||||
}
|
||||
},
|
||||
"noVehiclesFound": "No vehicles found",
|
||||
"tryDifferentSearch": "Try different dates, city, or car type",
|
||||
"couldNotLoadVehicles": "Could not load vehicles",
|
||||
"checkConnectionRetry": "Check your connection and try again",
|
||||
"retry": "Retry",
|
||||
"myBookings": "My Bookings",
|
||||
"browse": "Browse",
|
||||
"signInToViewBookings": "Sign in to view your bookings",
|
||||
"carType": "Car type",
|
||||
"selectCity": "Select city",
|
||||
"allCities": "All cities",
|
||||
"searchCities": "Search cities…",
|
||||
"chooseLanguage": "Language",
|
||||
"languageName": "English",
|
||||
"perDay": "/day",
|
||||
"nDays": "{count, plural, =1{1 day} other{{count} days}}",
|
||||
"@nDays": {
|
||||
"placeholders": {
|
||||
"count": { "type": "num" }
|
||||
}
|
||||
},
|
||||
"total": "total",
|
||||
"reserveThisCar": "Reserve This Car",
|
||||
"lightTheme": "Light",
|
||||
"darkTheme": "Dark",
|
||||
"settings": "Settings",
|
||||
"anyPrice": "Any price",
|
||||
"price": "Price",
|
||||
"apply": "Apply",
|
||||
"selectAll": "Select all",
|
||||
"clearAll": "Clear all",
|
||||
"pickDates": "Pick dates",
|
||||
"pickupLocation": "Pickup location",
|
||||
"dropoffLocation": "Drop-off location"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"@@locale": "fr",
|
||||
"appName": "RentalDriveGo",
|
||||
"tagline": "La façon la plus simple de trouver\net réserver la voiture parfaite",
|
||||
"findYourCar": "Trouver ma voiture",
|
||||
"companySignIn": "Connexion tableau de bord entreprise",
|
||||
"sameDropoff": "Retour même lieu",
|
||||
"differentDropoff": "Retour autre lieu",
|
||||
"findCarToRent": "Trouver une voiture à louer",
|
||||
"allCarTypes": "Tous les types",
|
||||
"search": "Rechercher",
|
||||
"searching": "Recherche…",
|
||||
"vehiclesFound": "{count, plural, =0{Aucun véhicule trouvé} =1{1 véhicule trouvé} other{{count} véhicules trouvés}}",
|
||||
"@vehiclesFound": {
|
||||
"placeholders": {
|
||||
"count": { "type": "num" }
|
||||
}
|
||||
},
|
||||
"noVehiclesFound": "Aucun véhicule trouvé",
|
||||
"tryDifferentSearch": "Essayez d'autres dates, ville ou type",
|
||||
"couldNotLoadVehicles": "Impossible de charger les véhicules",
|
||||
"checkConnectionRetry": "Vérifiez votre connexion et réessayez",
|
||||
"retry": "Réessayer",
|
||||
"myBookings": "Mes réservations",
|
||||
"browse": "Parcourir",
|
||||
"signInToViewBookings": "Connectez-vous pour voir vos réservations",
|
||||
"carType": "Type de voiture",
|
||||
"selectCity": "Sélectionner une ville",
|
||||
"allCities": "Toutes les villes",
|
||||
"searchCities": "Rechercher des villes…",
|
||||
"chooseLanguage": "Langue",
|
||||
"languageName": "Français",
|
||||
"perDay": "/jour",
|
||||
"nDays": "{count, plural, =1{1 jour} other{{count} jours}}",
|
||||
"@nDays": {
|
||||
"placeholders": {
|
||||
"count": { "type": "num" }
|
||||
}
|
||||
},
|
||||
"total": "total",
|
||||
"reserveThisCar": "Réserver cette voiture",
|
||||
"lightTheme": "Clair",
|
||||
"darkTheme": "Sombre",
|
||||
"settings": "Paramètres",
|
||||
"anyPrice": "Tout prix",
|
||||
"price": "Prix",
|
||||
"apply": "Appliquer",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"clearAll": "Tout effacer",
|
||||
"pickDates": "Choisir les dates",
|
||||
"pickupLocation": "Lieu de prise en charge",
|
||||
"dropoffLocation": "Lieu de retour"
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations_ar.dart';
|
||||
import 'app_localizations_en.dart';
|
||||
import 'app_localizations_fr.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// Callers can lookup localized strings with an instance of AppLocalizations
|
||||
/// returned by `AppLocalizations.of(context)`.
|
||||
///
|
||||
/// Applications need to include `AppLocalizations.delegate()` in their app's
|
||||
/// `localizationDelegates` list, and the locales they support in the app's
|
||||
/// `supportedLocales` list. For example:
|
||||
///
|
||||
/// ```dart
|
||||
/// import 'l10n/app_localizations.dart';
|
||||
///
|
||||
/// return MaterialApp(
|
||||
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
/// supportedLocales: AppLocalizations.supportedLocales,
|
||||
/// home: MyApplicationHome(),
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// ## Update pubspec.yaml
|
||||
///
|
||||
/// Please make sure to update your pubspec.yaml to include the following
|
||||
/// packages:
|
||||
///
|
||||
/// ```yaml
|
||||
/// dependencies:
|
||||
/// # Internationalization support.
|
||||
/// flutter_localizations:
|
||||
/// sdk: flutter
|
||||
/// intl: any # Use the pinned version from flutter_localizations
|
||||
///
|
||||
/// # Rest of dependencies
|
||||
/// ```
|
||||
///
|
||||
/// ## iOS Applications
|
||||
///
|
||||
/// iOS applications define key application metadata, including supported
|
||||
/// locales, in an Info.plist file that is built into the application bundle.
|
||||
/// To configure the locales supported by your app, you’ll need to edit this
|
||||
/// file.
|
||||
///
|
||||
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
|
||||
/// Then, in the Project Navigator, open the Info.plist file under the Runner
|
||||
/// project’s Runner folder.
|
||||
///
|
||||
/// Next, select the Information Property List item, select Add Item from the
|
||||
/// Editor menu, then select Localizations from the pop-up menu.
|
||||
///
|
||||
/// Select and expand the newly-created Localizations item then, for each
|
||||
/// locale your application supports, add a new item and select the locale
|
||||
/// you wish to add from the pop-up menu in the Value field. This list should
|
||||
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
|
||||
/// property.
|
||||
abstract class AppLocalizations {
|
||||
AppLocalizations(String locale)
|
||||
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
||||
|
||||
final String localeName;
|
||||
|
||||
static AppLocalizations of(BuildContext context) {
|
||||
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
|
||||
}
|
||||
|
||||
static const LocalizationsDelegate<AppLocalizations> delegate =
|
||||
_AppLocalizationsDelegate();
|
||||
|
||||
/// A list of this localizations delegate along with the default localizations
|
||||
/// delegates.
|
||||
///
|
||||
/// Returns a list of localizations delegates containing this delegate along with
|
||||
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
|
||||
/// and GlobalWidgetsLocalizations.delegate.
|
||||
///
|
||||
/// Additional delegates can be added by appending to this list in
|
||||
/// MaterialApp. This list does not have to be used at all if a custom list
|
||||
/// of delegates is preferred or required.
|
||||
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
|
||||
<LocalizationsDelegate<dynamic>>[
|
||||
delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
];
|
||||
|
||||
/// A list of this localizations delegate's supported locales.
|
||||
static const List<Locale> supportedLocales = <Locale>[
|
||||
Locale('ar'),
|
||||
Locale('en'),
|
||||
Locale('fr'),
|
||||
];
|
||||
|
||||
/// No description provided for @appName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'RentalDriveGo'**
|
||||
String get appName;
|
||||
|
||||
/// No description provided for @tagline.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The easiest way to find\nand reserve your perfect car'**
|
||||
String get tagline;
|
||||
|
||||
/// No description provided for @findYourCar.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Find your car'**
|
||||
String get findYourCar;
|
||||
|
||||
/// No description provided for @companySignIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Company dashboard sign in'**
|
||||
String get companySignIn;
|
||||
|
||||
/// No description provided for @sameDropoff.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Same drop-off'**
|
||||
String get sameDropoff;
|
||||
|
||||
/// No description provided for @differentDropoff.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Different drop-off'**
|
||||
String get differentDropoff;
|
||||
|
||||
/// No description provided for @findCarToRent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Find a car to rent'**
|
||||
String get findCarToRent;
|
||||
|
||||
/// No description provided for @allCarTypes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All car types'**
|
||||
String get allCarTypes;
|
||||
|
||||
/// No description provided for @search.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search'**
|
||||
String get search;
|
||||
|
||||
/// No description provided for @searching.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Searching…'**
|
||||
String get searching;
|
||||
|
||||
/// No description provided for @vehiclesFound.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =0{No vehicles found} =1{1 vehicle found} other{{count} vehicles found}}'**
|
||||
String vehiclesFound(num count);
|
||||
|
||||
/// No description provided for @noVehiclesFound.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No vehicles found'**
|
||||
String get noVehiclesFound;
|
||||
|
||||
/// No description provided for @tryDifferentSearch.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Try different dates, city, or car type'**
|
||||
String get tryDifferentSearch;
|
||||
|
||||
/// No description provided for @couldNotLoadVehicles.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not load vehicles'**
|
||||
String get couldNotLoadVehicles;
|
||||
|
||||
/// No description provided for @checkConnectionRetry.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Check your connection and try again'**
|
||||
String get checkConnectionRetry;
|
||||
|
||||
/// No description provided for @retry.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Retry'**
|
||||
String get retry;
|
||||
|
||||
/// No description provided for @myBookings.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'My Bookings'**
|
||||
String get myBookings;
|
||||
|
||||
/// No description provided for @browse.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Browse'**
|
||||
String get browse;
|
||||
|
||||
/// No description provided for @signInToViewBookings.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign in to view your bookings'**
|
||||
String get signInToViewBookings;
|
||||
|
||||
/// No description provided for @carType.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Car type'**
|
||||
String get carType;
|
||||
|
||||
/// No description provided for @selectCity.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select city'**
|
||||
String get selectCity;
|
||||
|
||||
/// No description provided for @allCities.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All cities'**
|
||||
String get allCities;
|
||||
|
||||
/// No description provided for @searchCities.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search cities…'**
|
||||
String get searchCities;
|
||||
|
||||
/// No description provided for @chooseLanguage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Language'**
|
||||
String get chooseLanguage;
|
||||
|
||||
/// No description provided for @languageName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'English'**
|
||||
String get languageName;
|
||||
|
||||
/// No description provided for @perDay.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'/day'**
|
||||
String get perDay;
|
||||
|
||||
/// No description provided for @nDays.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 day} other{{count} days}}'**
|
||||
String nDays(num count);
|
||||
|
||||
/// No description provided for @total.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'total'**
|
||||
String get total;
|
||||
|
||||
/// No description provided for @reserveThisCar.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reserve This Car'**
|
||||
String get reserveThisCar;
|
||||
|
||||
/// No description provided for @lightTheme.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Light'**
|
||||
String get lightTheme;
|
||||
|
||||
/// No description provided for @darkTheme.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Dark'**
|
||||
String get darkTheme;
|
||||
|
||||
/// No description provided for @settings.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Settings'**
|
||||
String get settings;
|
||||
|
||||
/// No description provided for @anyPrice.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Any price'**
|
||||
String get anyPrice;
|
||||
|
||||
/// No description provided for @price.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Price'**
|
||||
String get price;
|
||||
|
||||
/// No description provided for @apply.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Apply'**
|
||||
String get apply;
|
||||
|
||||
/// No description provided for @selectAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select all'**
|
||||
String get selectAll;
|
||||
|
||||
/// No description provided for @clearAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear all'**
|
||||
String get clearAll;
|
||||
|
||||
/// No description provided for @pickDates.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pick dates'**
|
||||
String get pickDates;
|
||||
|
||||
/// No description provided for @pickupLocation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pickup location'**
|
||||
String get pickupLocation;
|
||||
|
||||
/// No description provided for @dropoffLocation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Drop-off location'**
|
||||
String get dropoffLocation;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
extends LocalizationsDelegate<AppLocalizations> {
|
||||
const _AppLocalizationsDelegate();
|
||||
|
||||
@override
|
||||
Future<AppLocalizations> load(Locale locale) {
|
||||
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
|
||||
}
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) =>
|
||||
<String>['ar', 'en', 'fr'].contains(locale.languageCode);
|
||||
|
||||
@override
|
||||
bool shouldReload(_AppLocalizationsDelegate old) => false;
|
||||
}
|
||||
|
||||
AppLocalizations lookupAppLocalizations(Locale locale) {
|
||||
// Lookup logic when only language code is specified.
|
||||
switch (locale.languageCode) {
|
||||
case 'ar':
|
||||
return AppLocalizationsAr();
|
||||
case 'en':
|
||||
return AppLocalizationsEn();
|
||||
case 'fr':
|
||||
return AppLocalizationsFr();
|
||||
}
|
||||
|
||||
throw FlutterError(
|
||||
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
|
||||
'an issue with the localizations generation tool. Please file an issue '
|
||||
'on GitHub with a reproducible sample app and the gen-l10n configuration '
|
||||
'that was used.',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for Arabic (`ar`).
|
||||
class AppLocalizationsAr extends AppLocalizations {
|
||||
AppLocalizationsAr([String locale = 'ar']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appName => 'RentalDriveGo';
|
||||
|
||||
@override
|
||||
String get tagline => 'أسهل طريقة للعثور\nوحجز سيارتك المثالية';
|
||||
|
||||
@override
|
||||
String get findYourCar => 'ابحث عن سيارتك';
|
||||
|
||||
@override
|
||||
String get companySignIn => 'تسجيل دخول لوحة تحكم الشركة';
|
||||
|
||||
@override
|
||||
String get sameDropoff => 'إرجاع نفس المكان';
|
||||
|
||||
@override
|
||||
String get differentDropoff => 'إرجاع مكان آخر';
|
||||
|
||||
@override
|
||||
String get findCarToRent => 'ابحث عن سيارة للإيجار';
|
||||
|
||||
@override
|
||||
String get allCarTypes => 'جميع أنواع السيارات';
|
||||
|
||||
@override
|
||||
String get search => 'بحث';
|
||||
|
||||
@override
|
||||
String get searching => 'جارٍ البحث…';
|
||||
|
||||
@override
|
||||
String vehiclesFound(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count سيارة متوفرة',
|
||||
few: '$count سيارات متوفرة',
|
||||
two: 'سيارتان متوفرتان',
|
||||
one: 'سيارة واحدة متوفرة',
|
||||
zero: 'لا توجد سيارات',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noVehiclesFound => 'لا توجد سيارات';
|
||||
|
||||
@override
|
||||
String get tryDifferentSearch => 'جرّب تواريخ أو مدينة أو نوع مختلف';
|
||||
|
||||
@override
|
||||
String get couldNotLoadVehicles => 'تعذّر تحميل السيارات';
|
||||
|
||||
@override
|
||||
String get checkConnectionRetry => 'تحقق من اتصالك وحاول مجدداً';
|
||||
|
||||
@override
|
||||
String get retry => 'إعادة المحاولة';
|
||||
|
||||
@override
|
||||
String get myBookings => 'حجوزاتي';
|
||||
|
||||
@override
|
||||
String get browse => 'تصفح';
|
||||
|
||||
@override
|
||||
String get signInToViewBookings => 'سجّل الدخول لعرض حجوزاتك';
|
||||
|
||||
@override
|
||||
String get carType => 'نوع السيارة';
|
||||
|
||||
@override
|
||||
String get selectCity => 'اختر المدينة';
|
||||
|
||||
@override
|
||||
String get allCities => 'جميع المدن';
|
||||
|
||||
@override
|
||||
String get searchCities => 'ابحث عن مدينة…';
|
||||
|
||||
@override
|
||||
String get chooseLanguage => 'اللغة';
|
||||
|
||||
@override
|
||||
String get languageName => 'العربية';
|
||||
|
||||
@override
|
||||
String get perDay => '/يوم';
|
||||
|
||||
@override
|
||||
String nDays(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count يوم',
|
||||
few: '$count أيام',
|
||||
two: 'يومان',
|
||||
one: 'يوم واحد',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get total => 'الإجمالي';
|
||||
|
||||
@override
|
||||
String get reserveThisCar => 'احجز هذه السيارة';
|
||||
|
||||
@override
|
||||
String get lightTheme => 'فاتح';
|
||||
|
||||
@override
|
||||
String get darkTheme => 'داكن';
|
||||
|
||||
@override
|
||||
String get settings => 'الإعدادات';
|
||||
|
||||
@override
|
||||
String get anyPrice => 'أي سعر';
|
||||
|
||||
@override
|
||||
String get price => 'السعر';
|
||||
|
||||
@override
|
||||
String get apply => 'تطبيق';
|
||||
|
||||
@override
|
||||
String get selectAll => 'تحديد الكل';
|
||||
|
||||
@override
|
||||
String get clearAll => 'مسح الكل';
|
||||
|
||||
@override
|
||||
String get pickDates => 'اختر التواريخ';
|
||||
|
||||
@override
|
||||
String get pickupLocation => 'موقع الاستلام';
|
||||
|
||||
@override
|
||||
String get dropoffLocation => 'موقع الإرجاع';
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for English (`en`).
|
||||
class AppLocalizationsEn extends AppLocalizations {
|
||||
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appName => 'RentalDriveGo';
|
||||
|
||||
@override
|
||||
String get tagline => 'The easiest way to find\nand reserve your perfect car';
|
||||
|
||||
@override
|
||||
String get findYourCar => 'Find your car';
|
||||
|
||||
@override
|
||||
String get companySignIn => 'Company dashboard sign in';
|
||||
|
||||
@override
|
||||
String get sameDropoff => 'Same drop-off';
|
||||
|
||||
@override
|
||||
String get differentDropoff => 'Different drop-off';
|
||||
|
||||
@override
|
||||
String get findCarToRent => 'Find a car to rent';
|
||||
|
||||
@override
|
||||
String get allCarTypes => 'All car types';
|
||||
|
||||
@override
|
||||
String get search => 'Search';
|
||||
|
||||
@override
|
||||
String get searching => 'Searching…';
|
||||
|
||||
@override
|
||||
String vehiclesFound(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count vehicles found',
|
||||
one: '1 vehicle found',
|
||||
zero: 'No vehicles found',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noVehiclesFound => 'No vehicles found';
|
||||
|
||||
@override
|
||||
String get tryDifferentSearch => 'Try different dates, city, or car type';
|
||||
|
||||
@override
|
||||
String get couldNotLoadVehicles => 'Could not load vehicles';
|
||||
|
||||
@override
|
||||
String get checkConnectionRetry => 'Check your connection and try again';
|
||||
|
||||
@override
|
||||
String get retry => 'Retry';
|
||||
|
||||
@override
|
||||
String get myBookings => 'My Bookings';
|
||||
|
||||
@override
|
||||
String get browse => 'Browse';
|
||||
|
||||
@override
|
||||
String get signInToViewBookings => 'Sign in to view your bookings';
|
||||
|
||||
@override
|
||||
String get carType => 'Car type';
|
||||
|
||||
@override
|
||||
String get selectCity => 'Select city';
|
||||
|
||||
@override
|
||||
String get allCities => 'All cities';
|
||||
|
||||
@override
|
||||
String get searchCities => 'Search cities…';
|
||||
|
||||
@override
|
||||
String get chooseLanguage => 'Language';
|
||||
|
||||
@override
|
||||
String get languageName => 'English';
|
||||
|
||||
@override
|
||||
String get perDay => '/day';
|
||||
|
||||
@override
|
||||
String nDays(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count days',
|
||||
one: '1 day',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get total => 'total';
|
||||
|
||||
@override
|
||||
String get reserveThisCar => 'Reserve This Car';
|
||||
|
||||
@override
|
||||
String get lightTheme => 'Light';
|
||||
|
||||
@override
|
||||
String get darkTheme => 'Dark';
|
||||
|
||||
@override
|
||||
String get settings => 'Settings';
|
||||
|
||||
@override
|
||||
String get anyPrice => 'Any price';
|
||||
|
||||
@override
|
||||
String get price => 'Price';
|
||||
|
||||
@override
|
||||
String get apply => 'Apply';
|
||||
|
||||
@override
|
||||
String get selectAll => 'Select all';
|
||||
|
||||
@override
|
||||
String get clearAll => 'Clear all';
|
||||
|
||||
@override
|
||||
String get pickDates => 'Pick dates';
|
||||
|
||||
@override
|
||||
String get pickupLocation => 'Pickup location';
|
||||
|
||||
@override
|
||||
String get dropoffLocation => 'Drop-off location';
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for French (`fr`).
|
||||
class AppLocalizationsFr extends AppLocalizations {
|
||||
AppLocalizationsFr([String locale = 'fr']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appName => 'RentalDriveGo';
|
||||
|
||||
@override
|
||||
String get tagline =>
|
||||
'La façon la plus simple de trouver\net réserver la voiture parfaite';
|
||||
|
||||
@override
|
||||
String get findYourCar => 'Trouver ma voiture';
|
||||
|
||||
@override
|
||||
String get companySignIn => 'Connexion tableau de bord entreprise';
|
||||
|
||||
@override
|
||||
String get sameDropoff => 'Retour même lieu';
|
||||
|
||||
@override
|
||||
String get differentDropoff => 'Retour autre lieu';
|
||||
|
||||
@override
|
||||
String get findCarToRent => 'Trouver une voiture à louer';
|
||||
|
||||
@override
|
||||
String get allCarTypes => 'Tous les types';
|
||||
|
||||
@override
|
||||
String get search => 'Rechercher';
|
||||
|
||||
@override
|
||||
String get searching => 'Recherche…';
|
||||
|
||||
@override
|
||||
String vehiclesFound(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count véhicules trouvés',
|
||||
one: '1 véhicule trouvé',
|
||||
zero: 'Aucun véhicule trouvé',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noVehiclesFound => 'Aucun véhicule trouvé';
|
||||
|
||||
@override
|
||||
String get tryDifferentSearch => 'Essayez d\'autres dates, ville ou type';
|
||||
|
||||
@override
|
||||
String get couldNotLoadVehicles => 'Impossible de charger les véhicules';
|
||||
|
||||
@override
|
||||
String get checkConnectionRetry => 'Vérifiez votre connexion et réessayez';
|
||||
|
||||
@override
|
||||
String get retry => 'Réessayer';
|
||||
|
||||
@override
|
||||
String get myBookings => 'Mes réservations';
|
||||
|
||||
@override
|
||||
String get browse => 'Parcourir';
|
||||
|
||||
@override
|
||||
String get signInToViewBookings =>
|
||||
'Connectez-vous pour voir vos réservations';
|
||||
|
||||
@override
|
||||
String get carType => 'Type de voiture';
|
||||
|
||||
@override
|
||||
String get selectCity => 'Sélectionner une ville';
|
||||
|
||||
@override
|
||||
String get allCities => 'Toutes les villes';
|
||||
|
||||
@override
|
||||
String get searchCities => 'Rechercher des villes…';
|
||||
|
||||
@override
|
||||
String get chooseLanguage => 'Langue';
|
||||
|
||||
@override
|
||||
String get languageName => 'Français';
|
||||
|
||||
@override
|
||||
String get perDay => '/jour';
|
||||
|
||||
@override
|
||||
String nDays(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count jours',
|
||||
one: '1 jour',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get total => 'total';
|
||||
|
||||
@override
|
||||
String get reserveThisCar => 'Réserver cette voiture';
|
||||
|
||||
@override
|
||||
String get lightTheme => 'Clair';
|
||||
|
||||
@override
|
||||
String get darkTheme => 'Sombre';
|
||||
|
||||
@override
|
||||
String get settings => 'Paramètres';
|
||||
|
||||
@override
|
||||
String get anyPrice => 'Tout prix';
|
||||
|
||||
@override
|
||||
String get price => 'Prix';
|
||||
|
||||
@override
|
||||
String get apply => 'Appliquer';
|
||||
|
||||
@override
|
||||
String get selectAll => 'Tout sélectionner';
|
||||
|
||||
@override
|
||||
String get clearAll => 'Tout effacer';
|
||||
|
||||
@override
|
||||
String get pickDates => 'Choisir les dates';
|
||||
|
||||
@override
|
||||
String get pickupLocation => 'Lieu de prise en charge';
|
||||
|
||||
@override
|
||||
String get dropoffLocation => 'Lieu de retour';
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'app.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: CarRentalApp()));
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ErrorView extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
const ErrorView({super.key, required this.message, this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Color(0xFFE02424)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Color(0xFF6B7280)),
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(onPressed: onRetry, child: const Text('Retry')),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
class LoadingList extends StatelessWidget {
|
||||
final int count;
|
||||
final double itemHeight;
|
||||
|
||||
const LoadingList({super.key, this.count = 5, this.itemHeight = 100});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: const Color(0xFFE5E7EB),
|
||||
highlightColor: const Color(0xFFF9FAFB),
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: count,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (_, _) => Container(
|
||||
height: itemHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/providers/locale_provider.dart';
|
||||
import '../core/providers/theme_provider.dart';
|
||||
|
||||
const _orange = Color(0xFFFF6B00);
|
||||
|
||||
/// Compact row of language pills + theme toggle.
|
||||
/// [onDark] = true when rendered on a dark background (landing screen).
|
||||
class PreferencesBar extends ConsumerWidget {
|
||||
final bool onDark;
|
||||
|
||||
const PreferencesBar({super.key, this.onDark = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentLocale = ref.watch(localeProvider);
|
||||
final themeMode = ref.watch(themeProvider);
|
||||
final isDark = themeMode == ThemeMode.dark;
|
||||
|
||||
final inactiveText = onDark
|
||||
? Colors.white.withValues(alpha: 0.55)
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
final activeText = Colors.white;
|
||||
final inactiveBg = onDark
|
||||
? Colors.white.withValues(alpha: 0.12)
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
|
||||
const langs = [
|
||||
('EN', Locale('en')),
|
||||
('FR', Locale('fr')),
|
||||
('AR', Locale('ar')),
|
||||
];
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Language pills
|
||||
...langs.map((item) {
|
||||
final (code, locale) = item;
|
||||
final selected =
|
||||
currentLocale.languageCode == locale.languageCode;
|
||||
return GestureDetector(
|
||||
onTap: () => ref.read(localeProvider.notifier).setLocale(locale),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 11, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? _orange : inactiveBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
code,
|
||||
style: TextStyle(
|
||||
color: selected ? activeText : inactiveText,
|
||||
fontSize: 12,
|
||||
fontWeight:
|
||||
selected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
const SizedBox(width: 2),
|
||||
|
||||
// Theme toggle icon
|
||||
GestureDetector(
|
||||
onTap: () => ref.read(themeProvider.notifier).toggle(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(7),
|
||||
decoration: BoxDecoration(
|
||||
color: inactiveBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(
|
||||
isDark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
|
||||
size: 15,
|
||||
color: onDark
|
||||
? Colors.white.withValues(alpha: 0.8)
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../core/models/reservation.dart';
|
||||
import 'status_badge.dart';
|
||||
|
||||
class ReservationCard extends StatelessWidget {
|
||||
final Reservation reservation;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const ReservationCard({super.key, required this.reservation, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fmt = DateFormat('MMM d, yyyy');
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
reservation.vehicle?.displayName ?? 'Reservation',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
StatusBadge(status: reservation.status),
|
||||
],
|
||||
),
|
||||
if (reservation.customer != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
reservation.customer!.fullName,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: Color(0xFF6B7280)),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.calendar_today,
|
||||
size: 14, color: Color(0xFF9CA3AF)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${fmt.format(reservation.startDate)} → ${fmt.format(reservation.endDate)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: Color(0xFF6B7280)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.attach_money,
|
||||
size: 14, color: Color(0xFF9CA3AF)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'\$${reservation.totalAmount.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF111928),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
StatusBadge(status: reservation.paymentStatus),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class StatCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String? subtitle;
|
||||
|
||||
const StatCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 20),
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF111928),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF9CA3AF),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class StatusBadge extends StatelessWidget {
|
||||
final String status;
|
||||
|
||||
const StatusBadge({super.key, required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (color, bg, label) = _style(status);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static (Color, Color, String) _style(String status) {
|
||||
switch (status.toUpperCase()) {
|
||||
case 'AVAILABLE':
|
||||
return (const Color(0xFF057A55), const Color(0xFFDEF7EC), 'Available');
|
||||
case 'RENTED':
|
||||
return (const Color(0xFF1A56DB), const Color(0xFFE1EFFE), 'Rented');
|
||||
case 'MAINTENANCE':
|
||||
return (const Color(0xFFB45309), const Color(0xFFFDF6B2), 'Maintenance');
|
||||
case 'OUT_OF_SERVICE':
|
||||
return (const Color(0xFF9B1C1C), const Color(0xFFFDE8E8), 'Out of Service');
|
||||
case 'CONFIRMED':
|
||||
return (const Color(0xFF057A55), const Color(0xFFDEF7EC), 'Confirmed');
|
||||
case 'ACTIVE':
|
||||
return (const Color(0xFF1A56DB), const Color(0xFFE1EFFE), 'Active');
|
||||
case 'COMPLETED':
|
||||
return (const Color(0xFF5521B5), const Color(0xFFEDEBFE), 'Completed');
|
||||
case 'CANCELLED':
|
||||
return (const Color(0xFF9B1C1C), const Color(0xFFFDE8E8), 'Cancelled');
|
||||
case 'DRAFT':
|
||||
return (const Color(0xFF374151), const Color(0xFFF3F4F6), 'Draft');
|
||||
case 'NO_SHOW':
|
||||
return (const Color(0xFF9B1C1C), const Color(0xFFFDE8E8), 'No Show');
|
||||
case 'PENDING':
|
||||
return (const Color(0xFFB45309), const Color(0xFFFDF6B2), 'Pending');
|
||||
case 'SUCCEEDED':
|
||||
return (const Color(0xFF057A55), const Color(0xFFDEF7EC), 'Paid');
|
||||
case 'FAILED':
|
||||
return (const Color(0xFF9B1C1C), const Color(0xFFFDE8E8), 'Failed');
|
||||
case 'APPROVED':
|
||||
return (const Color(0xFF057A55), const Color(0xFFDEF7EC), 'Approved');
|
||||
case 'DENIED':
|
||||
return (const Color(0xFF9B1C1C), const Color(0xFFFDE8E8), 'Denied');
|
||||
case 'VALID':
|
||||
return (const Color(0xFF057A55), const Color(0xFFDEF7EC), 'Valid');
|
||||
case 'EXPIRED':
|
||||
return (const Color(0xFF9B1C1C), const Color(0xFFFDE8E8), 'Expired');
|
||||
default:
|
||||
return (const Color(0xFF374151), const Color(0xFFF3F4F6), status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/models/vehicle.dart';
|
||||
import '../core/constants/app_constants.dart';
|
||||
import 'status_badge.dart';
|
||||
|
||||
class VehicleCard extends StatelessWidget {
|
||||
final Vehicle vehicle;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const VehicleCard({super.key, required this.vehicle, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildImage(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
vehicle.displayName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
StatusBadge(status: vehicle.status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
vehicle.licensePlate,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.attach_money,
|
||||
size: 16, color: Color(0xFF1A56DB)),
|
||||
Text(
|
||||
'${vehicle.dailyRate.toStringAsFixed(0)}/day',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1A56DB),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF3F4F6),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
vehicle.category,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: Color(0xFF6B7280)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImage() {
|
||||
final photo = vehicle.primaryPhoto;
|
||||
if (photo == null) {
|
||||
return Container(
|
||||
height: 150,
|
||||
color: const Color(0xFFF3F4F6),
|
||||
child: const Center(
|
||||
child: Icon(Icons.directions_car, size: 48, color: Color(0xFFD1D5DB)),
|
||||
),
|
||||
);
|
||||
}
|
||||
final url = photo.startsWith('http')
|
||||
? photo
|
||||
: '${AppConstants.baseUrl.replaceAll('/api/v1', '')}$photo';
|
||||
return CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
height: 150,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, _) => Container(
|
||||
height: 150,
|
||||
color: const Color(0xFFF3F4F6),
|
||||
child: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
),
|
||||
errorWidget: (_, _, _) => Container(
|
||||
height: 150,
|
||||
color: const Color(0xFFF3F4F6),
|
||||
child: const Center(
|
||||
child: Icon(Icons.directions_car, size: 48, color: Color(0xFFD1D5DB)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user