add dashboard to phone app

This commit is contained in:
root
2026-05-25 05:10:43 -04:00
parent d04c8ce437
commit 277db06d26
67 changed files with 7872 additions and 570 deletions
@@ -7,14 +7,22 @@ import '../providers/client_providers.dart';
import '../../../core/models/marketplace.dart';
import '../../../l10n/app_localizations.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',
'ECONOMY',
'COMPACT',
'MIDSIZE',
'FULLSIZE',
'SUV',
'LUXURY',
'VAN',
'TRUCK',
];
String _capitalize(String s) =>
@@ -62,8 +70,9 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
endDate: _endDate,
categories: _selectedTypes.isEmpty ? null : Set.from(_selectedTypes),
maxPriceCents: _priceFilterActive ? _maxPriceCents : null,
minPriceCents:
_priceFilterActive && _minPriceCents > 0 ? _minPriceCents : null,
minPriceCents: _priceFilterActive && _minPriceCents > 0
? _minPriceCents
: null,
);
}
@@ -78,9 +87,9 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
? 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),
),
data: Theme.of(
context,
).copyWith(colorScheme: cs.copyWith(primary: _orange)),
child: child!,
),
);
@@ -174,7 +183,9 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
}
List<MarketplaceVehicle> _applyClientFilters(
List<MarketplaceVehicle> all, SearchParams params) {
List<MarketplaceVehicle> all,
SearchParams params,
) {
var result = all;
final cats = params.categories;
if (cats != null && cats.length > 1) {
@@ -199,14 +210,14 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
final typeLabel = _selectedTypes.isEmpty
? l10n.allCarTypes
: _selectedTypes.length == 1
? _capitalize(_selectedTypes.first)
: '${_selectedTypes.length} types';
? _capitalize(_selectedTypes.first)
: '${_selectedTypes.length} types';
final priceLabel = _priceFilterActive
? '\$${(_minPriceCents / 100).toStringAsFixed(0)} \$${(_maxPriceCents / 100).toStringAsFixed(0)}/day'
: l10n.anyPrice;
return Scaffold(
return ClientShell(
backgroundColor: cs.surface,
body: CustomScrollView(
slivers: [
@@ -268,12 +279,14 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
children: [
// Pickup
_SearchInputBox(
onTap: () => citiesAsync
.whenData((c) => _showCitySheet(c, l10n)),
onTap: () => citiesAsync.whenData(
(c) => _showCitySheet(c, l10n),
),
child: _LocationRow(
icon: Icons.radio_button_checked,
iconColor: _orange,
text: _city ??
text:
_city ??
(_sameDropoff
? l10n.findCarToRent
: l10n.pickupLocation),
@@ -307,17 +320,20 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
// Drop-off box
_SearchInputBox(
onTap: () => citiesAsync.whenData(
(c) => _showDropoffCitySheet(c, l10n)),
(c) => _showDropoffCitySheet(c, l10n),
),
child: _LocationRow(
icon: Icons.location_on,
iconColor: _blue,
text: _dropoffCity ??
text:
_dropoffCity ??
l10n.dropoffLocation,
placeholder: _dropoffCity == null,
cs: cs,
onClear: _dropoffCity != null
? () => setState(
() => _dropoffCity = null)
() => _dropoffCity = null,
)
: null,
),
),
@@ -343,13 +359,18 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
)
: Row(
children: [
Icon(Icons.calendar_today_outlined,
size: 18, color: cs.onSurfaceVariant),
Icon(
Icons.calendar_today_outlined,
size: 18,
color: cs.onSurfaceVariant,
),
const SizedBox(width: 12),
Text(
l10n.pickDates,
style: TextStyle(
color: cs.onSurfaceVariant, fontSize: 15),
color: cs.onSurfaceVariant,
fontSize: 15,
),
),
],
),
@@ -382,10 +403,10 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
onTap: () => _showPriceSheet(l10n),
onClear: _priceFilterActive
? () => setState(() {
_priceFilterActive = false;
_minPriceCents = 0;
_maxPriceCents = 50000;
})
_priceFilterActive = false;
_minPriceCents = 0;
_maxPriceCents = 50000;
})
: null,
),
),
@@ -426,28 +447,38 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
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,
loading: () => Row(
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: cs.onSurfaceVariant,
),
),
),
const SizedBox(width: 8),
Text(l10n.searching,
const SizedBox(width: 8),
Text(
l10n.searching,
style: TextStyle(
color: cs.onSurfaceVariant, fontSize: 13)),
]),
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
],
),
error: (_, _) => const SizedBox.shrink(),
data: (r) {
final count =
_applyClientFilters(r.data, params).length;
final count = _applyClientFilters(
r.data,
params,
).length;
return Text(
l10n.vehiclesFound(count),
style: TextStyle(
color: cs.onSurfaceVariant, fontSize: 13),
color: cs.onSurfaceVariant,
fontSize: 13,
),
);
},
),
@@ -458,12 +489,60 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
),
),
// ── 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),
color: _orange,
strokeWidth: 2.5,
),
),
),
error: (_, _) => SliverFillRemaining(
@@ -477,7 +556,8 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
final vehicles = _applyClientFilters(result.data, params);
if (vehicles.isEmpty) {
return SliverFillRemaining(
child: _EmptyBody(l10n: l10n, cs: cs));
child: _EmptyBody(l10n: l10n, cs: cs),
);
}
return SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
@@ -511,8 +591,11 @@ class _DropoffTab extends StatelessWidget {
final bool active;
final VoidCallback onTap;
const _DropoffTab(
{required this.label, required this.active, required this.onTap});
const _DropoffTab({
required this.label,
required this.active,
required this.onTap,
});
@override
Widget build(BuildContext context) {
@@ -579,26 +662,25 @@ class _LocationRow extends StatelessWidget {
@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,
),
),
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),
),
],
);
),
),
if (onClear != null)
GestureDetector(
onTap: onClear,
child: Icon(Icons.close, size: 16, color: cs.onSurfaceVariant),
),
],
);
}
// ── Date range display ────────────────────────────────────────────────────────
@@ -629,8 +711,11 @@ class _DateRangeRow extends StatelessWidget {
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Icon(Icons.arrow_forward,
size: 14, color: cs.onSurfaceVariant),
child: Icon(
Icons.arrow_forward,
size: 14,
color: cs.onSurfaceVariant,
),
),
_DateChunk(
date: dayFmt.format(end),
@@ -647,23 +732,23 @@ class _DateChunk extends StatelessWidget {
final String sub;
final ColorScheme cs;
const _DateChunk(
{required this.date, required this.sub, required this.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)),
],
);
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) ────────────────────────────────────────────
@@ -696,15 +781,11 @@ class _OptionTile extends StatelessWidget {
? _blue.withValues(alpha: 0.12)
: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: active ? _blue : cs.outlineVariant,
),
border: Border.all(color: active ? _blue : cs.outlineVariant),
),
child: Row(
children: [
Icon(icon,
size: 16,
color: active ? _blue : cs.onSurfaceVariant),
Icon(icon, size: 16, color: active ? _blue : cs.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: Text(
@@ -712,8 +793,7 @@ class _OptionTile extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight:
active ? FontWeight.w600 : FontWeight.normal,
fontWeight: active ? FontWeight.w600 : FontWeight.normal,
color: cs.onSurface,
),
),
@@ -722,9 +802,11 @@ class _OptionTile extends StatelessWidget {
const SizedBox(width: 4),
GestureDetector(
onTap: onClear,
child: Icon(Icons.close,
size: 14,
color: active ? _blue : cs.onSurfaceVariant),
child: Icon(
Icons.close,
size: 14,
color: active ? _blue : cs.onSurfaceVariant,
),
),
],
],
@@ -843,7 +925,11 @@ class _CarTypeSheetState extends State<_CarTypeSheet> {
),
Padding(
padding: EdgeInsets.fromLTRB(
16, 10, 16, 16 + MediaQuery.of(context).padding.bottom),
16,
10,
16,
16 + MediaQuery.of(context).padding.bottom,
),
child: SizedBox(
width: double.infinity,
height: 50,
@@ -853,12 +939,17 @@ class _CarTypeSheetState extends State<_CarTypeSheet> {
backgroundColor: _orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
child: Text(l10n.apply,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
child: Text(
l10n.apply,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
@@ -903,8 +994,9 @@ class _PriceSheetState extends State<_PriceSheet> {
);
}
String _fmt(double v) =>
v >= _PriceSheet._cap ? '\$${v.toStringAsFixed(0)}+' : '\$${v.toStringAsFixed(0)}';
String _fmt(double v) => v >= _PriceSheet._cap
? '\$${v.toStringAsFixed(0)}+'
: '\$${v.toStringAsFixed(0)}';
@override
Widget build(BuildContext context) {
@@ -912,8 +1004,9 @@ class _PriceSheetState extends State<_PriceSheet> {
final l10n = widget.l10n;
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -941,8 +1034,10 @@ class _PriceSheetState extends State<_PriceSheet> {
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(l10n.clearAll,
style: const TextStyle(fontSize: 14)),
child: Text(
l10n.clearAll,
style: const TextStyle(fontSize: 14),
),
),
],
),
@@ -955,8 +1050,7 @@ class _PriceSheetState extends State<_PriceSheet> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_PriceTag(label: _fmt(_range.start), cs: cs),
Icon(Icons.arrow_forward,
size: 14, color: cs.onSurfaceVariant),
Icon(Icons.arrow_forward, size: 14, color: cs.onSurfaceVariant),
_PriceTag(label: '${_fmt(_range.end)}/day', cs: cs),
],
),
@@ -987,7 +1081,11 @@ class _PriceSheetState extends State<_PriceSheet> {
Padding(
padding: EdgeInsets.fromLTRB(
16, 4, 16, 16 + MediaQuery.of(context).padding.bottom),
16,
4,
16,
16 + MediaQuery.of(context).padding.bottom,
),
child: SizedBox(
width: double.infinity,
height: 50,
@@ -1001,12 +1099,17 @@ class _PriceSheetState extends State<_PriceSheet> {
backgroundColor: _orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
child: Text(l10n.apply,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
child: Text(
l10n.apply,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
@@ -1024,21 +1127,21 @@ class _PriceTag extends StatelessWidget {
@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),
),
);
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 ────────────────────────────────────────────────────────────────
@@ -1089,9 +1192,10 @@ class _CitySheetState extends State<_CitySheet> {
Text(
widget.titleOverride ?? widget.l10n.selectCity,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: cs.onSurface),
fontSize: 17,
fontWeight: FontWeight.bold,
color: cs.onSurface,
),
),
const SizedBox(height: 12),
TextField(
@@ -1099,10 +1203,12 @@ class _CitySheetState extends State<_CitySheet> {
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),
prefixIcon: Icon(
Icons.search,
size: 20,
color: cs.onSurfaceVariant,
),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
),
onChanged: (v) => setState(() => _query = v),
),
@@ -1114,10 +1220,14 @@ class _CitySheetState extends State<_CitySheet> {
controller: controller,
children: [
ListTile(
leading: Icon(Icons.public_outlined,
color: cs.onSurfaceVariant),
title: Text(widget.l10n.allCities,
style: TextStyle(color: cs.onSurface)),
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
@@ -1125,19 +1235,21 @@ class _CitySheetState extends State<_CitySheet> {
: 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),
)),
...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),
),
),
],
),
),
@@ -1155,13 +1267,13 @@ class _SheetHandle extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
);
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
);
}
// ── Error / Empty states ─────────────────────────────────────────────────────
@@ -1171,46 +1283,48 @@ class _ErrorBody extends StatelessWidget {
final AppLocalizations l10n;
final ColorScheme cs;
const _ErrorBody(
{required this.onRetry, required this.l10n, required this.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),
),
],
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 {
@@ -1221,29 +1335,29 @@ class _EmptyBody extends StatelessWidget {
@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,
),
],
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,
),
],
),
),
);
}