1371 lines
45 KiB
Dart
1371 lines
45 KiB
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 '../providers/client_providers.dart';
|
||
import '../../../core/models/marketplace.dart';
|
||
import '../../../l10n/app_localizations.dart';
|
||
import '../../../widgets/app_back_button.dart';
|
||
import '../../../widgets/preferences_bar.dart';
|
||
import 'client_shell.dart';
|
||
import 'widgets/marketplace_vehicle_card.dart';
|
||
import 'widgets/offer_banner.dart';
|
||
|
||
const _orange = Color(0xFFFF6B00);
|
||
const _blue = Color(0xFF1A56DB);
|
||
|
||
const _allCarTypes = [
|
||
'ECONOMY',
|
||
'COMPACT',
|
||
'MIDSIZE',
|
||
'FULLSIZE',
|
||
'SUV',
|
||
'LUXURY',
|
||
'VAN',
|
||
'TRUCK',
|
||
];
|
||
|
||
String _capitalize(String s) =>
|
||
s.isEmpty ? s : s[0] + s.substring(1).toLowerCase();
|
||
|
||
// ── Screen ────────────────────────────────────────────────────────────────────
|
||
|
||
class ClientHomeScreen extends ConsumerStatefulWidget {
|
||
const ClientHomeScreen({super.key});
|
||
|
||
@override
|
||
ConsumerState<ClientHomeScreen> createState() => _ClientHomeScreenState();
|
||
}
|
||
|
||
class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
||
// Pending form state (committed to provider only when Search is pressed)
|
||
String? _city;
|
||
String? _dropoffCity;
|
||
DateTime? _startDate;
|
||
DateTime? _endDate;
|
||
Set<String> _selectedTypes = {};
|
||
int _minPriceCents = 0;
|
||
int _maxPriceCents = 50000;
|
||
bool _priceFilterActive = false;
|
||
bool _sameDropoff = true;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final params = ref.read(searchParamsProvider);
|
||
_city = params.city;
|
||
_startDate = params.startDate;
|
||
_endDate = params.endDate;
|
||
_selectedTypes = Set.from(params.categories ?? {});
|
||
_minPriceCents = params.minPriceCents ?? 0;
|
||
_maxPriceCents = params.maxPriceCents ?? 50000;
|
||
_priceFilterActive =
|
||
params.maxPriceCents != null || params.minPriceCents != null;
|
||
}
|
||
|
||
void _applySearch() {
|
||
ref.read(searchParamsProvider.notifier).state = SearchParams(
|
||
city: _city,
|
||
startDate: _startDate,
|
||
endDate: _endDate,
|
||
categories: _selectedTypes.isEmpty ? null : Set.from(_selectedTypes),
|
||
maxPriceCents: _priceFilterActive ? _maxPriceCents : null,
|
||
minPriceCents: _priceFilterActive && _minPriceCents > 0
|
||
? _minPriceCents
|
||
: null,
|
||
);
|
||
}
|
||
|
||
Future<void> _pickDates() async {
|
||
final cs = Theme.of(context).colorScheme;
|
||
final now = DateTime.now();
|
||
final picked = await showDateRangePicker(
|
||
context: context,
|
||
firstDate: now,
|
||
lastDate: now.add(const Duration(days: 365)),
|
||
initialDateRange: _startDate != null && _endDate != null
|
||
? DateTimeRange(start: _startDate!, end: _endDate!)
|
||
: DateTimeRange(start: now, end: now.add(const Duration(days: 1))),
|
||
builder: (context, child) => Theme(
|
||
data: Theme.of(
|
||
context,
|
||
).copyWith(colorScheme: cs.copyWith(primary: _orange)),
|
||
child: child!,
|
||
),
|
||
);
|
||
if (picked != null && mounted) {
|
||
setState(() {
|
||
_startDate = picked.start;
|
||
_endDate = picked.end;
|
||
});
|
||
}
|
||
}
|
||
|
||
void _showCitySheet(List<String> cities, AppLocalizations l10n) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (_) => _CitySheet(
|
||
cities: cities,
|
||
selected: _city,
|
||
l10n: l10n,
|
||
onSelect: (city) {
|
||
Navigator.pop(context);
|
||
setState(() => _city = city);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showDropoffCitySheet(List<String> cities, AppLocalizations l10n) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (_) => _CitySheet(
|
||
cities: cities,
|
||
selected: _dropoffCity,
|
||
l10n: l10n,
|
||
titleOverride: l10n.dropoffLocation,
|
||
onSelect: (city) {
|
||
Navigator.pop(context);
|
||
setState(() => _dropoffCity = city);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showCarTypeSheet(AppLocalizations l10n) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (_) => _CarTypeSheet(
|
||
selected: Set.from(_selectedTypes),
|
||
l10n: l10n,
|
||
onApply: (types) {
|
||
Navigator.pop(context);
|
||
setState(() => _selectedTypes = types);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showPriceSheet(AppLocalizations l10n) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (_) => _PriceSheet(
|
||
minCents: _minPriceCents,
|
||
maxCents: _maxPriceCents,
|
||
active: _priceFilterActive,
|
||
l10n: l10n,
|
||
onApply: (min, max, active) {
|
||
Navigator.pop(context);
|
||
setState(() {
|
||
_minPriceCents = min;
|
||
_maxPriceCents = max;
|
||
_priceFilterActive = active;
|
||
});
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
List<MarketplaceVehicle> _applyClientFilters(
|
||
List<MarketplaceVehicle> all,
|
||
SearchParams params,
|
||
) {
|
||
var result = all;
|
||
final cats = params.categories;
|
||
if (cats != null && cats.length > 1) {
|
||
result = result.where((v) => cats.contains(v.category)).toList();
|
||
}
|
||
final minP = params.minPriceCents;
|
||
if (minP != null && minP > 0) {
|
||
result = result.where((v) => v.dailyRateCents >= minP).toList();
|
||
}
|
||
return result;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
final l10n = AppLocalizations.of(context);
|
||
final params = ref.watch(searchParamsProvider);
|
||
final searchAsync = ref.watch(vehicleSearchProvider(params));
|
||
final citiesAsync = ref.watch(citiesProvider);
|
||
final langCode = Localizations.localeOf(context).languageCode;
|
||
|
||
final typeLabel = _selectedTypes.isEmpty
|
||
? l10n.allCarTypes
|
||
: _selectedTypes.length == 1
|
||
? _capitalize(_selectedTypes.first)
|
||
: '${_selectedTypes.length} types';
|
||
|
||
final priceLabel = _priceFilterActive
|
||
? '${(_minPriceCents / 100).toStringAsFixed(0)} – ${(_maxPriceCents / 100).toStringAsFixed(0)} MAD/day'
|
||
: l10n.anyPrice;
|
||
|
||
return ClientShell(
|
||
backgroundColor: cs.surface,
|
||
body: CustomScrollView(
|
||
slivers: [
|
||
// ── Search form ──────────────────────────────────────────────────
|
||
SliverToBoxAdapter(
|
||
child: SafeArea(
|
||
bottom: false,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Title + preferences
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 16, 12, 0),
|
||
child: Row(
|
||
children: [
|
||
AppBackButton(
|
||
fallbackRoute: '/landing',
|
||
color: cs.onSurface,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Expanded(
|
||
child: Text(
|
||
l10n.findCarToRent,
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
),
|
||
const PreferencesBar(),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// Same / Different drop-off toggle
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||
child: Row(
|
||
children: [
|
||
_DropoffTab(
|
||
label: l10n.sameDropoff,
|
||
active: _sameDropoff,
|
||
onTap: () => setState(() {
|
||
_sameDropoff = true;
|
||
_dropoffCity = null;
|
||
}),
|
||
),
|
||
const SizedBox(width: 28),
|
||
_DropoffTab(
|
||
label: l10n.differentDropoff,
|
||
active: !_sameDropoff,
|
||
onTap: () => setState(() => _sameDropoff = false),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// Location field(s)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Column(
|
||
children: [
|
||
// Pickup
|
||
_SearchInputBox(
|
||
onTap: () => citiesAsync.whenData(
|
||
(c) => _showCitySheet(c, l10n),
|
||
),
|
||
child: _LocationRow(
|
||
icon: Icons.radio_button_checked,
|
||
iconColor: _orange,
|
||
text:
|
||
_city ??
|
||
(_sameDropoff
|
||
? l10n.findCarToRent
|
||
: l10n.pickupLocation),
|
||
placeholder: _city == null,
|
||
cs: cs,
|
||
onClear: _city != null
|
||
? () => setState(() => _city = null)
|
||
: null,
|
||
),
|
||
),
|
||
|
||
// Animated drop-off field
|
||
AnimatedSize(
|
||
duration: const Duration(milliseconds: 250),
|
||
curve: Curves.easeInOut,
|
||
child: _sameDropoff
|
||
? const SizedBox.shrink()
|
||
: Column(
|
||
children: [
|
||
// Connector line
|
||
Row(
|
||
children: [
|
||
const SizedBox(width: 23),
|
||
Container(
|
||
width: 2,
|
||
height: 16,
|
||
color: cs.outlineVariant,
|
||
),
|
||
],
|
||
),
|
||
// Drop-off box
|
||
_SearchInputBox(
|
||
onTap: () => citiesAsync.whenData(
|
||
(c) => _showDropoffCitySheet(c, l10n),
|
||
),
|
||
child: _LocationRow(
|
||
icon: Icons.location_on,
|
||
iconColor: _blue,
|
||
text:
|
||
_dropoffCity ??
|
||
l10n.dropoffLocation,
|
||
placeholder: _dropoffCity == null,
|
||
cs: cs,
|
||
onClear: _dropoffCity != null
|
||
? () => setState(
|
||
() => _dropoffCity = null,
|
||
)
|
||
: null,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
|
||
// Date range field
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: _SearchInputBox(
|
||
onTap: _pickDates,
|
||
child: _startDate != null && _endDate != null
|
||
? _DateRangeRow(
|
||
start: _startDate!,
|
||
end: _endDate!,
|
||
langCode: langCode,
|
||
cs: cs,
|
||
)
|
||
: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.calendar_today_outlined,
|
||
size: 18,
|
||
color: cs.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: 12),
|
||
Text(
|
||
l10n.pickDates,
|
||
style: TextStyle(
|
||
color: cs.onSurfaceVariant,
|
||
fontSize: 15,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
|
||
// Car type + Price chips
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: _OptionTile(
|
||
label: typeLabel,
|
||
icon: Icons.directions_car_outlined,
|
||
active: _selectedTypes.isNotEmpty,
|
||
onTap: () => _showCarTypeSheet(l10n),
|
||
onClear: _selectedTypes.isNotEmpty
|
||
? () => setState(() => _selectedTypes = {})
|
||
: null,
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: _OptionTile(
|
||
label: priceLabel,
|
||
icon: Icons.attach_money_rounded,
|
||
active: _priceFilterActive,
|
||
onTap: () => _showPriceSheet(l10n),
|
||
onClear: _priceFilterActive
|
||
? () => setState(() {
|
||
_priceFilterActive = false;
|
||
_minPriceCents = 0;
|
||
_maxPriceCents = 50000;
|
||
})
|
||
: null,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// Search button
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: SizedBox(
|
||
width: double.infinity,
|
||
height: 54,
|
||
child: ElevatedButton(
|
||
onPressed: _applySearch,
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: _orange,
|
||
foregroundColor: Colors.white,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
elevation: 0,
|
||
),
|
||
child: Text(
|
||
l10n.search,
|
||
style: const TextStyle(
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// Results count line
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 10),
|
||
child: searchAsync.when(
|
||
loading: () => Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 12,
|
||
height: 12,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 1.5,
|
||
color: cs.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
l10n.searching,
|
||
style: TextStyle(
|
||
color: cs.onSurfaceVariant,
|
||
fontSize: 13,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
error: (_, _) => const SizedBox.shrink(),
|
||
data: (r) {
|
||
final count = _applyClientFilters(
|
||
r.data,
|
||
params,
|
||
).length;
|
||
return Text(
|
||
l10n.vehiclesFound(count),
|
||
style: TextStyle(
|
||
color: cs.onSurfaceVariant,
|
||
fontSize: 13,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
Divider(height: 1, color: cs.outlineVariant),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
// ── Active offers ────────────────────────────────────────────────
|
||
ref
|
||
.watch(offersProvider)
|
||
.when(
|
||
loading: () =>
|
||
const SliverToBoxAdapter(child: SizedBox.shrink()),
|
||
error: (_, _) =>
|
||
const SliverToBoxAdapter(child: SizedBox.shrink()),
|
||
data: (offers) {
|
||
if (offers.isEmpty) {
|
||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||
}
|
||
return SliverToBoxAdapter(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 10),
|
||
child: Text(
|
||
'Special Offers',
|
||
style: TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.bold,
|
||
color: cs.onSurface,
|
||
),
|
||
),
|
||
),
|
||
SizedBox(
|
||
height: 110,
|
||
child: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
itemCount: offers.length,
|
||
separatorBuilder: (_, _) =>
|
||
const SizedBox(width: 10),
|
||
itemBuilder: (_, i) =>
|
||
OfferBanner(offer: offers[i]),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
|
||
// ── Results ──────────────────────────────────────────────────────
|
||
searchAsync.when(
|
||
loading: () => const SliverFillRemaining(
|
||
child: Center(
|
||
child: CircularProgressIndicator(
|
||
color: _orange,
|
||
strokeWidth: 2.5,
|
||
),
|
||
),
|
||
),
|
||
error: (_, _) => SliverFillRemaining(
|
||
child: _ErrorBody(
|
||
onRetry: () => ref.invalidate(vehicleSearchProvider(params)),
|
||
l10n: l10n,
|
||
cs: cs,
|
||
),
|
||
),
|
||
data: (result) {
|
||
final vehicles = _applyClientFilters(result.data, params);
|
||
if (vehicles.isEmpty) {
|
||
return SliverFillRemaining(
|
||
child: _EmptyBody(l10n: l10n, cs: cs),
|
||
);
|
||
}
|
||
return SliverPadding(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
|
||
sliver: SliverList.separated(
|
||
itemCount: vehicles.length,
|
||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||
itemBuilder: (_, i) {
|
||
final v = vehicles[i];
|
||
return MarketplaceVehicleCard(
|
||
vehicle: v,
|
||
selectedStart: params.startDate,
|
||
selectedEnd: params.endDate,
|
||
onTap: () =>
|
||
context.push('/client/vehicles/${v.id}', extra: v),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Drop-off toggle tab ───────────────────────────────────────────────────────
|
||
|
||
class _DropoffTab extends StatelessWidget {
|
||
final String label;
|
||
final bool active;
|
||
final VoidCallback onTap;
|
||
|
||
const _DropoffTab({
|
||
required this.label,
|
||
required this.active,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontSize: 17,
|
||
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
|
||
color: active ? cs.onSurface : cs.onSurfaceVariant,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Rounded input container ───────────────────────────────────────────────────
|
||
|
||
class _SearchInputBox extends StatelessWidget {
|
||
final VoidCallback onTap;
|
||
final Widget child;
|
||
|
||
const _SearchInputBox({required this.onTap, required this.child});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
|
||
decoration: BoxDecoration(
|
||
color: cs.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(14),
|
||
border: Border.all(color: cs.outlineVariant),
|
||
),
|
||
child: child,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Location row (used in pickup + drop-off fields) ───────────────────────────
|
||
|
||
class _LocationRow extends StatelessWidget {
|
||
final IconData icon;
|
||
final Color iconColor;
|
||
final String text;
|
||
final bool placeholder;
|
||
final ColorScheme cs;
|
||
final VoidCallback? onClear;
|
||
|
||
const _LocationRow({
|
||
required this.icon,
|
||
required this.iconColor,
|
||
required this.text,
|
||
required this.placeholder,
|
||
required this.cs,
|
||
this.onClear,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Row(
|
||
children: [
|
||
Icon(icon, size: 18, color: iconColor),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Text(
|
||
text,
|
||
style: TextStyle(
|
||
color: placeholder ? cs.onSurfaceVariant : cs.onSurface,
|
||
fontSize: 15,
|
||
),
|
||
),
|
||
),
|
||
if (onClear != null)
|
||
GestureDetector(
|
||
onTap: onClear,
|
||
child: Icon(Icons.close, size: 16, color: cs.onSurfaceVariant),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ── Date range display ────────────────────────────────────────────────────────
|
||
|
||
class _DateRangeRow extends StatelessWidget {
|
||
final DateTime start;
|
||
final DateTime end;
|
||
final String langCode;
|
||
final ColorScheme cs;
|
||
|
||
const _DateRangeRow({
|
||
required this.start,
|
||
required this.end,
|
||
required this.langCode,
|
||
required this.cs,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final dayFmt = DateFormat('MMM d', langCode);
|
||
final dowFmt = DateFormat('EEE', langCode);
|
||
return Row(
|
||
children: [
|
||
_DateChunk(
|
||
date: dayFmt.format(start),
|
||
sub: '${dowFmt.format(start)} 12:00',
|
||
cs: cs,
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||
child: Icon(
|
||
Icons.arrow_forward,
|
||
size: 14,
|
||
color: cs.onSurfaceVariant,
|
||
),
|
||
),
|
||
_DateChunk(
|
||
date: dayFmt.format(end),
|
||
sub: '${dowFmt.format(end)} 12:00',
|
||
cs: cs,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DateChunk extends StatelessWidget {
|
||
final String date;
|
||
final String sub;
|
||
final ColorScheme cs;
|
||
|
||
const _DateChunk({required this.date, required this.sub, required this.cs});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Row(
|
||
children: [
|
||
Text(
|
||
date,
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(sub, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ── Option tile (car type / price) ────────────────────────────────────────────
|
||
|
||
class _OptionTile extends StatelessWidget {
|
||
final String label;
|
||
final IconData icon;
|
||
final bool active;
|
||
final VoidCallback onTap;
|
||
final VoidCallback? onClear;
|
||
|
||
const _OptionTile({
|
||
required this.label,
|
||
required this.icon,
|
||
required this.active,
|
||
required this.onTap,
|
||
this.onClear,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 180),
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||
decoration: BoxDecoration(
|
||
color: active
|
||
? _blue.withValues(alpha: 0.12)
|
||
: cs.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(14),
|
||
border: Border.all(color: active ? _blue : cs.outlineVariant),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(icon, size: 16, color: active ? _blue : cs.onSurfaceVariant),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
label,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: active ? FontWeight.w600 : FontWeight.normal,
|
||
color: cs.onSurface,
|
||
),
|
||
),
|
||
),
|
||
if (onClear != null) ...[
|
||
const SizedBox(width: 4),
|
||
GestureDetector(
|
||
onTap: onClear,
|
||
child: Icon(
|
||
Icons.close,
|
||
size: 14,
|
||
color: active ? _blue : cs.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Car Type Sheet ────────────────────────────────────────────────────────────
|
||
|
||
class _CarTypeSheet extends StatefulWidget {
|
||
final Set<String> selected;
|
||
final AppLocalizations l10n;
|
||
final ValueChanged<Set<String>> onApply;
|
||
|
||
const _CarTypeSheet({
|
||
required this.selected,
|
||
required this.l10n,
|
||
required this.onApply,
|
||
});
|
||
|
||
@override
|
||
State<_CarTypeSheet> createState() => _CarTypeSheetState();
|
||
}
|
||
|
||
class _CarTypeSheetState extends State<_CarTypeSheet> {
|
||
late Set<String> _selected;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_selected = Set.from(widget.selected);
|
||
}
|
||
|
||
bool get _allSelected => _selected.length == _allCarTypes.length;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
final l10n = widget.l10n;
|
||
|
||
return DraggableScrollableSheet(
|
||
initialChildSize: 0.7,
|
||
maxChildSize: 0.9,
|
||
minChildSize: 0.5,
|
||
expand: false,
|
||
builder: (_, controller) => Column(
|
||
children: [
|
||
const SizedBox(height: 12),
|
||
_SheetHandle(cs: cs),
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 6),
|
||
child: Row(
|
||
children: [
|
||
Text(
|
||
l10n.carType,
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
TextButton(
|
||
onPressed: () {
|
||
setState(() {
|
||
if (_allSelected) {
|
||
_selected = {};
|
||
} else {
|
||
_selected = Set.from(_allCarTypes);
|
||
}
|
||
});
|
||
},
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: _orange,
|
||
padding: EdgeInsets.zero,
|
||
minimumSize: Size.zero,
|
||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
),
|
||
child: Text(
|
||
_allSelected ? l10n.clearAll : l10n.selectAll,
|
||
style: const TextStyle(fontSize: 14),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: ListView(
|
||
controller: controller,
|
||
children: _allCarTypes.map((type) {
|
||
final checked = _selected.contains(type);
|
||
return CheckboxListTile(
|
||
value: checked,
|
||
onChanged: (_) {
|
||
setState(() {
|
||
if (checked) {
|
||
_selected.remove(type);
|
||
} else {
|
||
_selected.add(type);
|
||
}
|
||
});
|
||
},
|
||
title: Text(
|
||
_capitalize(type),
|
||
style: TextStyle(color: cs.onSurface, fontSize: 15),
|
||
),
|
||
activeColor: _orange,
|
||
checkColor: Colors.white,
|
||
side: BorderSide(color: cs.outlineVariant),
|
||
controlAffinity: ListTileControlAffinity.leading,
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
Padding(
|
||
padding: EdgeInsets.fromLTRB(
|
||
16,
|
||
10,
|
||
16,
|
||
16 + MediaQuery.of(context).padding.bottom,
|
||
),
|
||
child: SizedBox(
|
||
width: double.infinity,
|
||
height: 50,
|
||
child: ElevatedButton(
|
||
onPressed: () => widget.onApply(_selected),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: _orange,
|
||
foregroundColor: Colors.white,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
elevation: 0,
|
||
),
|
||
child: Text(
|
||
l10n.apply,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Price Sheet ───────────────────────────────────────────────────────────────
|
||
|
||
class _PriceSheet extends StatefulWidget {
|
||
final int minCents;
|
||
final int maxCents;
|
||
final bool active;
|
||
final AppLocalizations l10n;
|
||
final void Function(int min, int max, bool active) onApply;
|
||
|
||
static const double _cap = 500; // $500/day cap
|
||
|
||
const _PriceSheet({
|
||
required this.minCents,
|
||
required this.maxCents,
|
||
required this.active,
|
||
required this.l10n,
|
||
required this.onApply,
|
||
});
|
||
|
||
@override
|
||
State<_PriceSheet> createState() => _PriceSheetState();
|
||
}
|
||
|
||
class _PriceSheetState extends State<_PriceSheet> {
|
||
late RangeValues _range;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_range = RangeValues(
|
||
(widget.minCents / 100).clamp(0, _PriceSheet._cap).toDouble(),
|
||
(widget.maxCents / 100).clamp(0, _PriceSheet._cap).toDouble(),
|
||
);
|
||
}
|
||
|
||
String _fmt(double v) => v >= _PriceSheet._cap
|
||
? '${v.toStringAsFixed(0)}+ MAD'
|
||
: '${v.toStringAsFixed(0)} MAD';
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
final l10n = widget.l10n;
|
||
|
||
return Padding(
|
||
padding: EdgeInsets.only(
|
||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const SizedBox(height: 12),
|
||
_SheetHandle(cs: cs),
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 6),
|
||
child: Row(
|
||
children: [
|
||
Text(
|
||
l10n.price,
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
if (widget.active)
|
||
TextButton(
|
||
onPressed: () => widget.onApply(0, 50000, false),
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: _orange,
|
||
padding: EdgeInsets.zero,
|
||
minimumSize: Size.zero,
|
||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
),
|
||
child: Text(
|
||
l10n.clearAll,
|
||
style: const TextStyle(fontSize: 14),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// Min / max labels
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
_PriceTag(label: _fmt(_range.start), cs: cs),
|
||
Icon(Icons.arrow_forward, size: 14, color: cs.onSurfaceVariant),
|
||
_PriceTag(label: '${_fmt(_range.end)}/day', cs: cs),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
|
||
// Range slider
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
child: SliderTheme(
|
||
data: SliderTheme.of(context).copyWith(
|
||
activeTrackColor: _orange,
|
||
inactiveTrackColor: cs.outlineVariant,
|
||
thumbColor: _orange,
|
||
overlayColor: _orange.withValues(alpha: 0.15),
|
||
trackHeight: 4,
|
||
),
|
||
child: RangeSlider(
|
||
values: _range,
|
||
min: 0,
|
||
max: _PriceSheet._cap,
|
||
divisions: 50,
|
||
onChanged: (v) => setState(() => _range = v),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
|
||
Padding(
|
||
padding: EdgeInsets.fromLTRB(
|
||
16,
|
||
4,
|
||
16,
|
||
16 + MediaQuery.of(context).padding.bottom,
|
||
),
|
||
child: SizedBox(
|
||
width: double.infinity,
|
||
height: 50,
|
||
child: ElevatedButton(
|
||
onPressed: () => widget.onApply(
|
||
(_range.start * 100).round(),
|
||
(_range.end * 100).round(),
|
||
true,
|
||
),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: _orange,
|
||
foregroundColor: Colors.white,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
elevation: 0,
|
||
),
|
||
child: Text(
|
||
l10n.apply,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PriceTag extends StatelessWidget {
|
||
final String label;
|
||
final ColorScheme cs;
|
||
|
||
const _PriceTag({required this.label, required this.cs});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: cs.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: cs.outlineVariant),
|
||
),
|
||
child: Text(
|
||
label,
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 15,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── City Sheet ────────────────────────────────────────────────────────────────
|
||
|
||
class _CitySheet extends StatefulWidget {
|
||
final List<String> cities;
|
||
final String? selected;
|
||
final AppLocalizations l10n;
|
||
final ValueChanged<String?> onSelect;
|
||
final String? titleOverride;
|
||
|
||
const _CitySheet({
|
||
required this.cities,
|
||
required this.selected,
|
||
required this.l10n,
|
||
required this.onSelect,
|
||
this.titleOverride,
|
||
});
|
||
|
||
@override
|
||
State<_CitySheet> createState() => _CitySheetState();
|
||
}
|
||
|
||
class _CitySheetState extends State<_CitySheet> {
|
||
String _query = '';
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
final filtered = widget.cities
|
||
.where((c) => c.toLowerCase().contains(_query.toLowerCase()))
|
||
.toList();
|
||
|
||
return DraggableScrollableSheet(
|
||
initialChildSize: 0.6,
|
||
maxChildSize: 0.9,
|
||
minChildSize: 0.4,
|
||
expand: false,
|
||
builder: (_, controller) => Column(
|
||
children: [
|
||
const SizedBox(height: 12),
|
||
_SheetHandle(cs: cs),
|
||
Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
widget.titleOverride ?? widget.l10n.selectCity,
|
||
style: TextStyle(
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.bold,
|
||
color: cs.onSurface,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
autofocus: true,
|
||
style: TextStyle(color: cs.onSurface),
|
||
decoration: InputDecoration(
|
||
hintText: widget.l10n.searchCities,
|
||
prefixIcon: Icon(
|
||
Icons.search,
|
||
size: 20,
|
||
color: cs.onSurfaceVariant,
|
||
),
|
||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||
),
|
||
onChanged: (v) => setState(() => _query = v),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: ListView(
|
||
controller: controller,
|
||
children: [
|
||
ListTile(
|
||
leading: Icon(
|
||
Icons.public_outlined,
|
||
color: cs.onSurfaceVariant,
|
||
),
|
||
title: Text(
|
||
widget.l10n.allCities,
|
||
style: TextStyle(color: cs.onSurface),
|
||
),
|
||
selected: widget.selected == null,
|
||
selectedColor: _orange,
|
||
trailing: widget.selected == null
|
||
? const Icon(Icons.check, color: _orange, size: 18)
|
||
: null,
|
||
onTap: () => widget.onSelect(null),
|
||
),
|
||
...filtered.map(
|
||
(city) => ListTile(
|
||
leading: Icon(
|
||
Icons.location_city_outlined,
|
||
color: cs.onSurfaceVariant,
|
||
),
|
||
title: Text(city, style: TextStyle(color: cs.onSurface)),
|
||
selected: widget.selected == city,
|
||
selectedColor: _orange,
|
||
trailing: widget.selected == city
|
||
? const Icon(Icons.check, color: _orange, size: 18)
|
||
: null,
|
||
onTap: () => widget.onSelect(city),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Shared sheet drag handle ──────────────────────────────────────────────────
|
||
|
||
class _SheetHandle extends StatelessWidget {
|
||
final ColorScheme cs;
|
||
const _SheetHandle({required this.cs});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Container(
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: cs.outlineVariant,
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Error / Empty states ─────────────────────────────────────────────────────
|
||
|
||
class _ErrorBody extends StatelessWidget {
|
||
final VoidCallback onRetry;
|
||
final AppLocalizations l10n;
|
||
final ColorScheme cs;
|
||
|
||
const _ErrorBody({
|
||
required this.onRetry,
|
||
required this.l10n,
|
||
required this.cs,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(32),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.wifi_off_outlined, size: 48, color: cs.onSurfaceVariant),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
l10n.couldNotLoadVehicles,
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
l10n.checkConnectionRetry,
|
||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
const SizedBox(height: 20),
|
||
OutlinedButton(
|
||
onPressed: onRetry,
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: _orange,
|
||
side: const BorderSide(color: _orange),
|
||
),
|
||
child: Text(l10n.retry),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
class _EmptyBody extends StatelessWidget {
|
||
final AppLocalizations l10n;
|
||
final ColorScheme cs;
|
||
|
||
const _EmptyBody({required this.l10n, required this.cs});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(32),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.search_off, size: 56, color: cs.outlineVariant),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
l10n.noVehiclesFound,
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
l10n.tryDifferentSearch,
|
||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|