Files
2026-05-27 03:11:45 -04:00

1371 lines
45 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
],
),
),
);
}