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
@@ -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}'),
),
),
),
),
);
}
}