first app design

This commit is contained in:
root
2026-05-24 02:35:37 -04:00
parent c842eed601
commit d04c8ce437
138 changed files with 9672 additions and 0 deletions
+40
View File
@@ -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,
],
);
}
}
+14
View File
@@ -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';
}
+40
View File
@@ -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,
);
}
+54
View File
@@ -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,
);
}
+70
View File
@@ -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,
);
}
+223
View File
@@ -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>),
);
}
+137
View File
@@ -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,
);
}
+81
View File
@@ -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,
);
}
+92
View File
@@ -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));
});
+29
View File
@@ -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(),
);
+34
View File
@@ -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(),
);
+159
View File
@@ -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;
});
+11
View File
@@ -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>);
}
}
+58
View File
@@ -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);
}
+38
View File
@@ -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();
}
+29
View File
@@ -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});
}
}
+40
View File
@@ -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});
}
}
+22
View File
@@ -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();
}
+177
View File
@@ -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),
],
),
),
),
);
}
}
+138
View File
@@ -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}'),
),
),
),
),
);
}
}
+53
View File
@@ -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": "موقع الإرجاع"
}
+53
View File
@@ -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"
}
+53
View File
@@ -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"
}
+378
View File
@@ -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, youll need to edit this
/// file.
///
/// First, open your projects ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// projects 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.',
);
}
+151
View File
@@ -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 => 'موقع الإرجاع';
}
+147
View File
@@ -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';
}
+149
View File
@@ -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';
}
+7
View File
@@ -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()));
}
+33
View File
@@ -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')),
],
],
),
),
);
}
}
+29
View File
@@ -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),
),
),
),
);
}
}
+89
View File
@@ -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,
),
),
),
],
);
}
}
+82
View File
@@ -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),
],
),
],
),
),
),
);
}
}
+72
View File
@@ -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),
),
),
],
],
),
),
);
}
}
+68
View File
@@ -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);
}
}
}
+122
View File
@@ -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)),
),
),
);
}
}