Initial commit

This commit is contained in:
2026-06-23 11:29:07 +05:30
commit 401cf600e8
224 changed files with 12165 additions and 0 deletions

View File

@@ -0,0 +1,843 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import 'package:geocoding/geocoding.dart';
import 'package:doormile/main.dart';
import 'package:doormile/shared/state/app_state.dart';
import 'package:doormile/core/theme/app_colors.dart';
import 'package:doormile/core/theme/app_theme.dart';
import 'package:doormile/core/widgets/common.dart';
import 'package:doormile/features/booking/screens/booking_scaffold.dart';
class BookingPickupScreen extends StatefulWidget {
const BookingPickupScreen({super.key});
@override
State<BookingPickupScreen> createState() => _BookingPickupScreenState();
}
class _BookingPickupScreenState extends State<BookingPickupScreen> {
late final TextEditingController _pickup;
late final TextEditingController _destPincode;
late final TextEditingController _name;
late final TextEditingController _mobile;
late final TextEditingController _notes;
late final _formKey = GlobalKey<FormState>();
bool _loading = false;
String _category = '';
String _serviceType = '';
@override
void initState() {
super.initState();
final draft = context.read<AppState>().draft;
_pickup = TextEditingController(text: draft.pickupAddress);
_destPincode = TextEditingController(text: draft.dropPincode);
_name = TextEditingController(text: draft.contactName);
_mobile = TextEditingController(text: draft.contactMobile);
_notes = TextEditingController(text: draft.notes);
_category = draft.parcelType;
_serviceType = draft.service;
}
@override
void dispose() {
_pickup.dispose();
_destPincode.dispose();
_name.dispose();
_mobile.dispose();
_notes.dispose();
super.dispose();
}
void _showValidationError(String title, String message) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (ctx) => Container(
padding: const EdgeInsets.all(24),
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline_rounded, size: 56, color: AppColors.error),
const SizedBox(height: 16),
Text(title, style: AppTheme.headlineSm),
const SizedBox(height: 8),
Text(
message,
textAlign: TextAlign.center,
style: AppTheme.bodyLg.copyWith(color: AppColors.onSurfaceVariant),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(ctx),
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: const Text('Got it'),
),
),
],
),
),
),
);
}
Future<void> _submit() async {
if (_pickup.text.trim().isEmpty) {
_showValidationError('Missing Location', 'Please enter or select a pickup address where the Miler should arrive.');
return;
}
if (_destPincode.text.trim().length < 6) {
_showValidationError('Missing Pincode', 'Please enter the 6-digit receiver pincode so we can estimate the fare.');
return;
}
if (_category.isEmpty) {
_showValidationError('Missing Category', 'Please select a parcel category so we can assign the right Miler.');
return;
}
if (_serviceType.isEmpty) {
_showValidationError('Missing Service Type', 'Please select whether you need Normal or Express delivery.');
return;
}
final appState = context.read<AppState>();
final draft = appState.draft;
draft.pickupAddress = _pickup.text;
draft.dropPincode = _destPincode.text.trim();
draft.contactName = _name.text;
draft.contactMobile = _mobile.text;
draft.notes = _notes.text;
draft.parcelType = _category;
draft.service = _serviceType;
setState(() => _loading = true);
await appState.fetchPriceEstimate();
setState(() => _loading = false);
if (mounted) Navigator.pushNamed(context, Routes.bookingPreview);
}
@override
Widget build(BuildContext context) {
final state = context.watch<AppState>();
return BookingScaffold(
title: 'Book a Pickup',
step: 1,
stepLabel: 'Pickup Request',
buttonLabel: 'Review Booking',
enabled: !_loading,
loading: _loading,
onContinue: _submit,
children: [
// Pickup location card
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: AppColors.success.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
),
child: const Icon(Icons.location_on, size: 14, color: AppColors.success),
),
const SizedBox(width: 10),
Text('PICKUP LOCATION',
style: AppTheme.caption.copyWith(
letterSpacing: 1, fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 8),
TextField(
controller: _pickup,
style: AppTheme.headlineSm,
cursorColor: AppColors.primary,
maxLines: 2,
minLines: 1,
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: 'Enter pickup address manually',
),
),
const Divider(height: 20, color: AppColors.surfaceContainer),
InkWell(
onTap: () async {
setState(() => _loading = true);
try {
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
throw Exception('Location permissions are denied');
}
}
if (permission == LocationPermission.deniedForever) {
if (!mounted) return;
setState(() => _loading = false);
bool? open = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Permission Denied'),
content: const Text('Location permissions are permanently denied. Please enable them in app settings.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Settings')),
],
),
);
if (open == true) await Geolocator.openAppSettings();
return;
}
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
if (!mounted) return;
setState(() => _loading = false);
_showLocationDisabledSheet();
return;
}
Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
List<Placemark> placemarks = await placemarkFromCoordinates(position.latitude, position.longitude);
if (!mounted) return;
setState(() => _loading = false);
final state = context.read<AppState>();
if (placemarks.isNotEmpty) {
final place = placemarks.first;
final area = '${place.subLocality ?? place.locality}, ${place.administrativeArea}';
final pincode = place.postalCode ?? '';
final fullAddress = '${place.street}, $area, $pincode';
_showLocationConfirmationSheet(context, state, area, pincode, fullAddress);
} else {
throw Exception('Could not determine address from coordinates.');
}
} catch (e) {
if (!mounted) return;
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
);
}
},
child: Row(
children: [
if (_loading)
const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primary))
else
const Icon(Icons.my_location, size: 18, color: AppColors.primary),
const SizedBox(width: 8),
Flexible(
child: Text(_loading ? 'Locating...' : 'Use GPS current location',
style: AppTheme.labelMd.copyWith(color: AppColors.primary),
overflow: TextOverflow.ellipsis),
),
],
),
),
const Divider(height: 20, color: AppColors.surfaceContainer),
Text('SAVED ADDRESSES', style: AppTheme.caption.copyWith(letterSpacing: 1, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
child: Row(
children: [
_SavedAddressChip(
icon: Icons.home_rounded,
label: 'Home',
address: '12th Main, Indiranagar, Bengaluru, Karnataka 560038',
onSelect: (addr) => setState(() {
_pickup.text = addr;
final state = context.read<AppState>();
_name.text = state.firstName;
_mobile.text = state.phone;
}),
),
const SizedBox(width: 12),
_SavedAddressChip(
icon: Icons.business_rounded,
label: 'Office',
address: 'Sector 4, HSR Layout, Bengaluru, Karnataka 560102',
onSelect: (addr) => setState(() {
_pickup.text = addr;
final state = context.read<AppState>();
_name.text = state.firstName;
_mobile.text = state.phone;
}),
),
const SizedBox(width: 12),
_SavedAddressChip(
icon: Icons.favorite_rounded,
label: "Mom's House",
address: 'Jayanagar 4th Block, Bengaluru, Karnataka 560011',
onSelect: (addr) => setState(() {
_pickup.text = addr;
final state = context.read<AppState>();
_name.text = state.firstName;
_mobile.text = state.phone;
}),
),
],
),
),
],
),
),
const SizedBox(height: 16),
// Destination Pincode card
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
),
child: const Icon(Icons.pin_drop, size: 14, color: AppColors.primary),
),
const SizedBox(width: 10),
Text('RECEIVER PINCODE',
style: AppTheme.caption.copyWith(
letterSpacing: 1, fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 8),
TextField(
controller: _destPincode,
keyboardType: TextInputType.number,
maxLength: 6,
style: AppTheme.headlineSm,
cursorColor: AppColors.primary,
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
counterText: "",
hintText: 'Enter 6-digit delivery pincode',
),
),
],
),
),
const SizedBox(height: 16),
// Category Card
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Category',
style: AppTheme.caption.copyWith(
letterSpacing: 1, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
Column(
children: [
Row(
children: [
Expanded(child: _SelectionCard(width: null, icon: Icons.category, label: 'General Goods', selected: _category == 'General Goods', onTap: () => setState(() => _category = 'General Goods'))),
const SizedBox(width: 8),
Expanded(child: _SelectionCard(width: null, icon: Icons.menu_book, label: 'Books & Documents', selected: _category == 'Books & Documents', onTap: () => setState(() => _category = 'Books & Documents'))),
const SizedBox(width: 8),
Expanded(child: _SelectionCard(width: null, icon: Icons.devices, label: 'Electronics & Gadgets', selected: _category == 'Electronics & Gadgets', onTap: () => setState(() => _category = 'Electronics & Gadgets'))),
const SizedBox(width: 8),
Expanded(child: _SelectionCard(width: null, icon: Icons.checkroom, label: 'Clothing & Textiles', selected: _category == 'Clothing & Textiles', onTap: () => setState(() => _category = 'Clothing & Textiles'))),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _SelectionCard(width: null, icon: Icons.wine_bar, label: 'Fragile Items', selected: _category == 'Fragile Items', onTap: () => setState(() => _category = 'Fragile Items'))),
const SizedBox(width: 8),
Expanded(child: _SelectionCard(width: null, icon: Icons.medical_services, label: 'Medical & Pharma', selected: _category == 'Medical & Pharma', onTap: () => setState(() => _category = 'Medical & Pharma'))),
const SizedBox(width: 8),
Expanded(child: _SelectionCard(width: null, icon: Icons.directions_car, label: 'Automotive Parts', selected: _category == 'Automotive Parts', onTap: () => setState(() => _category = 'Automotive Parts'))),
const SizedBox(width: 8),
Expanded(child: _SelectionCard(width: null, icon: Icons.restaurant, label: 'Food & Perishables', selected: _category == 'Food & Perishables', onTap: () => setState(() => _category = 'Food & Perishables'))),
],
),
],
),
],
),
),
const SizedBox(height: 16),
// Type Card
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Service Type',
style: AppTheme.caption.copyWith(
letterSpacing: 1, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _ServiceCard(
title: 'Normal',
subtitle: 'Standard delivery',
icon: Icons.local_shipping_outlined,
selected: _serviceType == 'Normal',
onTap: () => setState(() => _serviceType = 'Normal'),
),
),
const SizedBox(width: 12),
Expanded(
child: _ServiceCard(
title: 'Express',
subtitle: 'Lightning fast',
icon: Icons.bolt,
selected: _serviceType == 'Express',
onTap: () => setState(() => _serviceType = 'Express'),
),
),
],
),
],
),
),
const SizedBox(height: 16),
// Contact Details Card
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Contact Person (Optional)',
style: AppTheme.caption.copyWith(
letterSpacing: 1, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
TextField(
controller: _name,
style: AppTheme.bodyLg,
decoration: InputDecoration(
hintText: 'Name (e.g. Arun)',
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primary),
),
),
),
const SizedBox(height: 12),
TextField(
controller: _mobile,
keyboardType: TextInputType.phone,
style: AppTheme.bodyLg,
decoration: InputDecoration(
hintText: 'Mobile Number',
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primary),
),
),
),
],
),
),
const SizedBox(height: 16),
// Notes Card
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Pickup Notes (Optional)',
style: AppTheme.caption.copyWith(
letterSpacing: 1, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
TextField(
controller: _notes,
maxLines: 3,
style: AppTheme.bodyLg,
decoration: InputDecoration(
hintText: 'Call before coming, Shop closes at 8 PM, etc.',
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primary),
),
),
),
],
),
),
const SizedBox(height: 24),
Text('RECENT & SAVED',
style: AppTheme.caption
.copyWith(letterSpacing: 1.2, fontWeight: FontWeight.w700)),
const SizedBox(height: 12),
...state.savedAddresses.map((a) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: AppCard(
onTap: () => setState(() => _pickup.text = '${a.label}, ${a.line}'),
child: Row(
children: [
IconBadge(
icon: a.icon, bg: a.iconBg, color: a.iconColor, circle: true),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(a.label, style: AppTheme.labelMd),
const SizedBox(height: 2),
Text(a.line, style: AppTheme.caption),
],
),
),
],
),
),
)),
const SizedBox(height: 8),
const EcoBanner(
text: 'Miler will collect parcel details, weight, and pricing upon arrival.'),
],
);
}
void _showLocationConfirmationSheet(BuildContext context, AppState state, String area, String pincode, String fullAddress) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => Container(
padding: const EdgeInsets.all(24),
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 120,
width: double.infinity,
decoration: BoxDecoration(
color: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
image: const DecorationImage(
image: AssetImage('assets/images/map_placeholder.png'), // Will failover smoothly if missing
fit: BoxFit.cover,
),
),
child: const Center(
child: IconBadge(icon: Icons.location_on, bg: AppColors.primaryContainer, color: AppColors.primary, circle: true),
),
),
const SizedBox(height: 24),
Text('GPS Location Found', style: AppTheme.headlineSm),
const SizedBox(height: 8),
Text(area, style: AppTheme.labelMd.copyWith(color: AppColors.primary)),
const SizedBox(height: 4),
Text('Pincode: $pincode', style: AppTheme.caption),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: PrimaryButton(
label: 'Confirm',
onPressed: () {
setState(() {
_pickup.text = fullAddress;
_name.text = state.firstName;
_mobile.text = state.phone;
});
Navigator.pop(context);
},
),
),
],
),
],
),
),
),
);
}
void _showLocationDisabledSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => Container(
padding: const EdgeInsets.all(24),
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.location_off_rounded, size: 64, color: AppColors.error),
const SizedBox(height: 16),
Text(
"Location is turned off",
style: AppTheme.headlineSm,
),
const SizedBox(height: 8),
Text(
"Please turn on location to accurately detect your pickup address.",
textAlign: TextAlign.center,
style: AppTheme.bodyLg.copyWith(color: AppColors.onSurfaceVariant),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(ctx),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: PrimaryButton(
label: 'Turn On',
onPressed: () async {
Navigator.pop(ctx);
await Geolocator.openLocationSettings();
},
),
),
],
),
],
),
),
),
);
}
}
class _SavedAddressChip extends StatelessWidget {
final IconData icon;
final String label;
final String address;
final Function(String) onSelect;
const _SavedAddressChip({
required this.icon,
required this.label,
required this.address,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => onSelect(address),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
border: Border.all(color: AppColors.surfaceContainerHigh),
borderRadius: BorderRadius.circular(12),
color: AppColors.surface,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: AppColors.primary),
const SizedBox(width: 8),
Text(label, style: AppTheme.labelMd),
],
),
),
);
}
}
class _SelectionCard extends StatelessWidget {
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
final double? width;
const _SelectionCard({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
this.width = 85,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: width,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
decoration: BoxDecoration(
color: selected ? AppColors.primaryFixed : AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: selected ? AppColors.primary : AppColors.surfaceContainerHigh,
width: selected ? 2 : 1,
),
boxShadow: selected
? [BoxShadow(color: AppColors.primary.withValues(alpha: 0.15), blurRadius: 12, offset: const Offset(0, 4))]
: [],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: selected ? AppColors.primary : AppColors.onSurfaceVariant, size: 24),
const SizedBox(height: 12),
Text(
label,
textAlign: TextAlign.center,
maxLines: 2,
style: AppTheme.labelMd.copyWith(
color: selected ? AppColors.primary : AppColors.onSurfaceVariant,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
fontSize: 10,
),
),
],
),
),
);
}
}
class _ServiceCard extends StatelessWidget {
final String title;
final String subtitle;
final IconData icon;
final bool selected;
final VoidCallback onTap;
const _ServiceCard({
required this.title,
required this.subtitle,
required this.icon,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
gradient: selected
? const LinearGradient(
colors: [AppColors.primaryContainer, AppColors.primary],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: null,
color: selected ? null : AppColors.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: selected ? AppColors.primary : AppColors.surfaceContainerHigh,
width: selected ? 2 : 1,
),
boxShadow: selected
? [BoxShadow(color: AppColors.primary.withValues(alpha: 0.3), blurRadius: 16, offset: const Offset(0, 6))]
: [],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: selected ? Colors.white.withValues(alpha: 0.2) : AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: selected ? Colors.white : AppColors.onSurfaceVariant, size: 24),
),
const SizedBox(height: 16),
Text(
title,
style: AppTheme.headlineSm.copyWith(
color: selected ? Colors.white : AppColors.onSurface,
fontSize: 14,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: AppTheme.caption.copyWith(
color: selected ? Colors.white.withValues(alpha: 0.8) : AppColors.onSurfaceVariant,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,252 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:doormile/main.dart';
import 'package:doormile/shared/state/app_state.dart';
import 'package:doormile/core/theme/app_colors.dart';
import 'package:doormile/core/theme/app_theme.dart';
import 'package:doormile/core/widgets/common.dart';
import 'package:doormile/features/booking/screens/booking_scaffold.dart';
import 'package:doormile/features/booking/screens/payment_sheet.dart';
class BookingPreviewScreen extends StatefulWidget {
const BookingPreviewScreen({super.key});
@override
State<BookingPreviewScreen> createState() => _BookingPreviewScreenState();
}
class _BookingPreviewScreenState extends State<BookingPreviewScreen> {
bool _loading = false;
void _confirmOrder() async {
setState(() => _loading = true);
try {
await context.read<AppState>().placeOrder();
if (!mounted) return;
setState(() => _loading = false);
Navigator.pushNamedAndRemoveUntil(context, Routes.bookingSuccess, ModalRoute.withName(Routes.home));
} catch (e) {
if (!mounted) return;
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
}
}
@override
Widget build(BuildContext context) {
final state = context.watch<AppState>();
final draft = state.draft;
return BookingScaffold(
title: 'Review Booking',
step: 4,
stepLabel: 'Step 4: Review',
buttonLabel: 'Confirm booking',
loading: _loading,
enabled: !_loading,
onContinue: _confirmOrder,
children: [
Center(
child: Column(
children: [
StatusChip(
label: 'Step 4 of 4',
color: AppColors.primary,
bg: AppColors.primaryFixed.withValues(alpha: 0.5),
),
const SizedBox(height: 10),
Text('Review Booking', style: AppTheme.headlineLg),
const SizedBox(height: 4),
Text('Please verify your delivery details before confirming payment.',
textAlign: TextAlign.center,
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
],
),
),
const SizedBox(height: 20),
// Pickup location
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.gps_fixed, size: 18, color: AppColors.primary),
const SizedBox(width: 8),
Expanded(child: Text('Pickup Location', style: AppTheme.labelMd)),
SecondaryButton(
label: 'Change',
onPressed: () => Navigator.popUntil(
context, (r) => r.settings.name == Routes.bookingPickup),
),
],
),
const SizedBox(height: 10),
Text(draft.pickupAddress,
style: AppTheme.headlineSm.copyWith(fontWeight: FontWeight.w700)),
Text(draft.dropPincode.isNotEmpty
? 'Drop details will be collected by Miler at Pincode: ${draft.dropPincode}'
: 'Drop details will be collected by Miler',
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
],
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _MiniCard(
icon: Icons.inventory_2,
title: 'Category',
value: draft.parcelType,
),
),
const SizedBox(width: 12),
Expanded(
child: _MiniCard(
icon: Icons.speed,
title: 'Service',
value: draft.service,
),
),
],
),
const SizedBox(height: 12),
AppCard(
border: Border.all(color: AppColors.outlineVariant),
child: Row(
children: [
const IconBadge(
icon: Icons.local_shipping,
bg: AppColors.primary,
color: Colors.white,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Doormile Collection',
style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)),
Text('Assigned optimal vehicle size', style: AppTheme.caption),
],
),
),
StatusChip(
label: 'EV First',
icon: Icons.eco,
color: AppColors.tertiary,
bg: AppColors.tertiaryFixed.withValues(alpha: 0.5),
),
],
),
),
const SizedBox(height: 12),
// Fare summary
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Estimated Price Range', style: AppTheme.headlineSm),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text('Final price determined at pickup',
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant), overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 12),
Text('${draft.minPrice.toInt()} - ₹${draft.maxPrice.toInt()}',
style: AppTheme.headlineMd.copyWith(color: AppColors.primary)),
],
),
],
),
),
const SizedBox(height: 12),
Center(
child: Text('Taxes and fees are included in the total price.',
style: AppTheme.caption),
),
],
);
}
Widget _fareRow(String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(label,
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant),
overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 12),
Text(value, style: AppTheme.labelMd),
],
),
);
}
class _MiniCard extends StatelessWidget {
final IconData icon;
final String title;
final String value;
final String? subtitle;
final List<String>? chips;
const _MiniCard({
required this.icon,
required this.title,
required this.value,
this.subtitle,
this.chips,
});
@override
Widget build(BuildContext context) {
return AppCard(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconBadge(
icon: icon,
bg: AppColors.surfaceContainer,
color: AppColors.primary,
size: 34,
iconSize: 18,
),
const SizedBox(height: 10),
Text(title, style: AppTheme.caption),
Text(value, style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)),
if (subtitle != null)
Text(subtitle!, style: AppTheme.caption.copyWith(color: AppColors.onSurfaceVariant)),
if (chips != null) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: chips!
.map((c) => Align(
alignment: Alignment.centerLeft,
child: FittedBox(
fit: BoxFit.scaleDown,
child: StatusChip(
label: c,
color: AppColors.secondary,
bg: AppColors.secondaryFixed,
),
),
))
.toList(),
),
],
],
),
);
}
}

View File

@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:doormile/core/theme/app_colors.dart';
import 'package:doormile/core/theme/app_theme.dart';
import 'package:doormile/core/widgets/common.dart';
/// Shared layout for the multi-step booking flow: app bar, step progress,
/// scrollable body, and a sticky bottom action button.
class BookingScaffold extends StatelessWidget {
final String title;
final int step; // 1..4
final String stepLabel;
final String buttonLabel;
final VoidCallback onContinue;
final List<Widget> children;
final Widget? bottomExtra;
final bool enabled;
final bool loading;
const BookingScaffold({
super.key,
required this.title,
required this.step,
required this.stepLabel,
required this.buttonLabel,
required this.onContinue,
required this.children,
this.bottomExtra,
this.enabled = true,
this.loading = false,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.surface,
body: AppBackground(
child: Column(
children: [
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [AppColors.primaryContainer, AppColors.primary],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.vertical(bottom: Radius.circular(24)),
),
child: ClipRRect(
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(24)),
child: Stack(
children: [
Positioned(
right: -20,
top: 10,
child: Transform.rotate(
angle: 0.1,
child: Icon(Icons.add_location_alt_rounded, size: 140, color: Colors.white.withValues(alpha: 0.04)),
),
),
SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 10, 16, 18),
child: Column(
children: [
Row(
children: [
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back, color: Colors.white),
),
Expanded(
child: Text(title,
overflow: TextOverflow.ellipsis,
style: AppTheme.headlineMd.copyWith(color: Colors.white)),
),
const Icon(Icons.location_on_outlined, color: Colors.white),
],
),
const SizedBox(height: 12),
AppCard(
padding: const EdgeInsets.all(14),
color: Colors.white.withValues(alpha: 0.94),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(stepLabel,
style: AppTheme.labelMd.copyWith(color: AppColors.primary),
overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 8),
Text('Step $step',
style: AppTheme.caption.copyWith(fontWeight: FontWeight.w700)),
],
),
const SizedBox(height: 10),
JourneyProgressBar(value: 1, gradient: true),
],
),
),
],
),
),
),
],
),
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 18, 16, 24),
children: children,
),
),
],
),
),
bottomNavigationBar: Container(
decoration: BoxDecoration(
color: AppColors.surfaceContainerLowest.withValues(alpha: 0.95),
border: const Border(top: BorderSide(color: AppColors.surfaceContainer)),
),
child: SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (bottomExtra != null) ...[bottomExtra!, const SizedBox(height: 10)],
PrimaryButton(
label: buttonLabel,
loading: loading,
trailingIcon: Icons.arrow_forward,
height: 54,
onPressed: enabled ? onContinue : null,
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:doormile/main.dart';
import 'package:doormile/shared/state/app_state.dart';
import 'package:doormile/core/theme/app_colors.dart';
import 'package:doormile/core/theme/app_theme.dart';
import 'package:doormile/core/widgets/common.dart';
class BookingSuccessScreen extends StatefulWidget {
const BookingSuccessScreen({super.key});
@override
State<BookingSuccessScreen> createState() => _BookingSuccessScreenState();
}
class _BookingSuccessScreenState extends State<BookingSuccessScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _c =
AnimationController(vsync: this, duration: const Duration(milliseconds: 600))..forward();
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final order = context.read<AppState>().orders.first;
return Scaffold(
backgroundColor: AppColors.surface,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
const Spacer(),
ScaleTransition(
scale: CurvedAnimation(parent: _c, curve: Curves.elasticOut),
child: Container(
width: 110,
height: 110,
decoration: BoxDecoration(
color: AppColors.success.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: const Icon(Icons.check_circle,
color: AppColors.success, size: 72),
),
),
const SizedBox(height: 28),
Text('Booking Confirmed!', style: AppTheme.headlineLg),
const SizedBox(height: 8),
Text(
'Your EV pickup is scheduled. Well notify you when the rider is on the way.',
textAlign: TextAlign.center,
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant),
),
const SizedBox(height: 24),
AppCard(
child: Column(
children: [
_row('Order ID', order.id),
const Divider(height: 22, color: AppColors.surfaceContainer),
_row('Estimated Fare', '${order.amount.toStringAsFixed(0)}'),
const Divider(height: 22, color: AppColors.surfaceContainer),
_row('Pickup Status', 'Waiting for Miler assignment'),
],
),
),
const SizedBox(height: 16),
const EcoBanner(
text: 'This delivery is 100% electric — offsetting ~1.2kg of CO₂.'),
const Spacer(),
PrimaryButton(
label: 'Track Live',
trailingIcon: Icons.navigation,
onPressed: () => Navigator.pushNamedAndRemoveUntil(
context,
Routes.liveTracking,
(r) => r.settings.name == Routes.home,
arguments: order,
),
),
const SizedBox(height: 12),
TextButton(
onPressed: () => Navigator.popUntil(
context, (r) => r.settings.name == Routes.home),
child: Text('Back to Home',
style: AppTheme.labelMd.copyWith(color: AppColors.primary)),
),
],
),
),
),
);
}
Widget _row(String k, String v) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(k, style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
const SizedBox(width: 12),
Flexible(
child: Text(v,
textAlign: TextAlign.right,
style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)),
),
],
);
}

View File

@@ -0,0 +1,349 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:doormile/main.dart';
import 'package:doormile/shared/state/app_state.dart';
import 'package:doormile/core/theme/app_colors.dart';
import 'package:doormile/core/theme/app_theme.dart';
import 'package:doormile/core/widgets/common.dart';
import 'package:doormile/features/booking/screens/booking_scaffold.dart';
class ParcelDetailsScreen extends StatefulWidget {
const ParcelDetailsScreen({super.key});
@override
State<ParcelDetailsScreen> createState() => _ParcelDetailsScreenState();
}
class _ParcelDetailsScreenState extends State<ParcelDetailsScreen> {
final _types = const [
('Documents', Icons.description),
('Electronics', Icons.devices),
('Food', Icons.restaurant),
('Clothing', Icons.checkroom),
('Fragile', Icons.auto_awesome),
('Other', Icons.more_horiz),
];
final _weights = const ['01 kg', '15 kg', '510 kg', '10 kg+'];
final _sizes = const [
('Small', 'Fits in a backpack (e.g. Phone, Keys)'),
('Medium', 'Fits in a courier box (e.g. Laptop, Shoes)'),
('Large', 'Requires vehicle trunk (e.g. Monitor, Luggage)'),
];
final _days = const ['Today', 'Tomorrow'];
final _slots = const ['10:00 AM - 12:00 PM', '12:00 PM - 02:00 PM', '02:00 PM - 04:00 PM'];
@override
Widget build(BuildContext context) {
final draft = context.watch<AppState>().draft;
return BookingScaffold(
title: 'Parcel Details',
step: 2,
stepLabel: 'Step 2: Details',
buttonLabel: 'Continue',
onContinue: () => Navigator.pushNamed(context, Routes.servicePrice),
children: [
Text('What are you sending?', style: AppTheme.headlineSm),
const SizedBox(height: 14),
GridView.count(
crossAxisCount: 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.05,
children: _types.map((t) {
final active = draft.parcelType == t.$1;
return _SelectTile(
active: active,
onTap: () {
draft.parcelType = t.$1;
context.read<AppState>().touch();
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(t.$2, color: active ? Colors.white : AppColors.primary, size: 26),
const SizedBox(height: 8),
Text(t.$1,
style: AppTheme.labelMd.copyWith(
color: active ? Colors.white : AppColors.onSurface)),
],
),
);
}).toList(),
),
const SizedBox(height: 24),
Text('Approximate Weight', style: AppTheme.headlineSm),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _weights
.map((w) => _Pill(
label: w,
active: draft.weight == w,
onTap: () {
draft.weight = w;
context.read<AppState>().touch();
},
))
.toList(),
),
const SizedBox(height: 24),
Text('Package Size', style: AppTheme.headlineSm),
const SizedBox(height: 12),
..._sizes.map((s) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _RadioCard(
active: draft.size == s.$1,
onTap: () {
draft.size = s.$1;
context.read<AppState>().touch();
},
child: Row(
children: [
IconBadge(
icon: Icons.inventory_2,
bg: AppColors.primaryContainer.withValues(alpha: 0.1),
color: AppColors.primary,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(s.$1,
style: AppTheme.labelMd
.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(s.$2, style: AppTheme.caption),
],
),
),
],
),
),
)),
const SizedBox(height: 12),
Text('Pickup Slot', style: AppTheme.headlineSm),
const SizedBox(height: 12),
Row(
children: _days
.map((d) => Padding(
padding: const EdgeInsets.only(right: 8),
child: _Pill(
label: d,
active: draft.pickupDay == d,
onTap: () {
draft.pickupDay = d;
context.read<AppState>().touch();
},
),
))
.toList(),
),
const SizedBox(height: 12),
..._slots.map((slot) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _RadioCard(
active: draft.pickupSlot == slot,
onTap: () {
draft.pickupSlot = slot;
context.read<AppState>().touch();
},
child: Text(slot,
style: AppTheme.bodyMd.copyWith(fontWeight: FontWeight.w700)),
),
)),
const SizedBox(height: 16),
// Fragile toggle
AppCard(
border: Border.all(color: AppColors.outlineVariant),
child: Row(
children: [
const Icon(Icons.warning_amber_rounded, color: AppColors.warning),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Mark as Fragile',
style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)),
Text('Handle with extra care', style: AppTheme.caption),
],
),
),
Switch(
value: draft.fragile,
activeTrackColor: AppColors.primary,
onChanged: (v) {
draft.fragile = v;
context.read<AppState>().touch();
},
),
],
),
),
const SizedBox(height: 16),
Text('Additional Notes (Optional)',
style: AppTheme.labelMd.copyWith(color: AppColors.onSurfaceVariant)),
const SizedBox(height: 8),
TextField(
maxLines: 3,
cursorColor: AppColors.primary,
onChanged: (v) => draft.notes = v,
decoration: InputDecoration(
hintText: 'Gate code, specific handling instructions, etc.',
hintStyle: AppTheme.bodyMd.copyWith(color: AppColors.outline),
filled: true,
fillColor: AppColors.surfaceContainerLowest,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppColors.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppColors.primary),
),
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.tertiaryFixedDim.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.tertiary.withValues(alpha: 0.1)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const IconBadge(
icon: Icons.electric_rickshaw,
bg: Color(0x1A005251),
color: AppColors.tertiary,
size: 40,
iconSize: 20,
circle: true,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('EV-First & 3-Wheeler Collection',
style: AppTheme.labelMd
.copyWith(color: AppColors.tertiary, fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(
'A Doormile agent arrives in a 3-wheeler during your slot. Delivered by EV to cut carbon footprint.',
style: AppTheme.caption
.copyWith(color: AppColors.onTertiaryFixedVariant),
),
],
),
),
],
),
),
],
);
}
}
// ---- Shared selection widgets ----
class _SelectTile extends StatelessWidget {
final bool active;
final Widget child;
final VoidCallback onTap;
const _SelectTile({required this.active, required this.child, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: active ? AppColors.primary : AppColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: active ? AppColors.primary : AppColors.outlineVariant),
),
child: child,
),
);
}
}
class _Pill extends StatelessWidget {
final String label;
final bool active;
final VoidCallback onTap;
const _Pill({required this.label, required this.active, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
decoration: BoxDecoration(
color: active ? AppColors.primary : AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(label,
style: AppTheme.labelMd
.copyWith(color: active ? Colors.white : AppColors.onSurface)),
),
);
}
}
class _RadioCard extends StatelessWidget {
final bool active;
final Widget child;
final VoidCallback onTap;
const _RadioCard({required this.active, required this.child, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: active ? const Color(0xFFF8E0E3) : AppColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: active ? AppColors.primary : AppColors.outlineVariant,
width: active ? 2 : 1,
),
),
child: Row(
children: [
Expanded(child: child),
const SizedBox(width: 12),
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: active ? AppColors.primary : AppColors.outline, width: 2),
),
child: active
? const Center(
child: CircleAvatar(radius: 5, backgroundColor: AppColors.primary))
: null,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:doormile/main.dart';
import 'package:doormile/shared/state/app_state.dart';
import 'package:doormile/core/theme/app_colors.dart';
import 'package:doormile/core/theme/app_theme.dart';
import 'package:doormile/core/widgets/common.dart';
Future<void> showPaymentSheet(BuildContext context, double amount) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (_) => _PaymentSheet(amount: amount),
);
}
class _Method {
final String name;
final String? sub;
final IconData icon;
final Color iconColor;
const _Method(this.name, this.icon, this.iconColor, {this.sub});
}
class _PaymentSheet extends StatefulWidget {
final double amount;
const _PaymentSheet({required this.amount});
@override
State<_PaymentSheet> createState() => _PaymentSheetState();
}
class _PaymentSheetState extends State<_PaymentSheet> {
final _methods = const [
_Method('UPI (GPay, PhonePe, Paytm)', Icons.account_balance, AppColors.secondary),
_Method('Credit / Debit Cards', Icons.credit_card, AppColors.onSurface),
_Method('Net banking', Icons.account_balance_outlined, AppColors.onSurface),
_Method('Cash on Delivery', Icons.payments_outlined, AppColors.onSurface),
];
int _selected = 0;
bool _processing = false;
void _pay() async {
setState(() => _processing = true);
final state = context.read<AppState>();
state.draft.paymentMethod = _methods[_selected].name;
await Future.delayed(const Duration(milliseconds: 1400));
if (!mounted) return;
state.placeOrder();
Navigator.pop(context); // close sheet
Navigator.pushNamedAndRemoveUntil(
context,
Routes.bookingSuccess,
(r) => r.settings.name == Routes.home,
);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 10),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(99)),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Pay ₹${widget.amount.toStringAsFixed(0)}', style: AppTheme.headlineLg),
Text('Order ID: 293810',
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
],
),
),
...List.generate(_methods.length, (i) {
final m = _methods[i];
final active = i == _selected;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
child: GestureDetector(
onTap: () => setState(() => _selected = i),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: active ? AppColors.surfaceContainer : AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: active ? AppColors.primary : AppColors.outlineVariant),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(10),
),
child: Icon(m.icon, color: m.iconColor, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(m.name,
style: AppTheme.labelMd
.copyWith(fontWeight: FontWeight.w700)),
if (m.sub != null)
Text(m.sub!,
style: AppTheme.caption.copyWith(color: AppColors.primary)),
],
),
),
Container(
width: 22,
height: 22,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: active ? AppColors.primary : AppColors.outline,
width: 2),
color: active ? AppColors.primary : Colors.transparent,
),
child: active
? const Icon(Icons.circle, size: 8, color: Colors.white)
: null,
),
],
),
),
),
);
}),
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: SafeArea(
top: false,
child: PrimaryButton(
label: 'Pay ₹${widget.amount.toStringAsFixed(0)}',
loading: _processing,
height: 54,
onPressed: _pay,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,253 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:doormile/main.dart';
import 'package:doormile/shared/state/app_state.dart';
import 'package:doormile/core/theme/app_colors.dart';
import 'package:doormile/core/theme/app_theme.dart';
import 'package:doormile/core/widgets/common.dart';
import 'package:doormile/features/booking/screens/booking_scaffold.dart';
class ServicePriceScreen extends StatelessWidget {
const ServicePriceScreen({super.key});
@override
Widget build(BuildContext context) {
final state = context.watch<AppState>();
final draft = state.draft;
return BookingScaffold(
title: 'Service & Price',
step: 3,
stepLabel: 'Step 3: Choose Service',
buttonLabel: 'Confirm & Continue',
onContinue: () => Navigator.pushNamed(context, Routes.bookingPreview),
children: [
Text('CHOOSE SERVICE',
style: AppTheme.caption
.copyWith(letterSpacing: 1.2, fontWeight: FontWeight.w700)),
const SizedBox(height: 12),
_ServiceCard(
active: draft.service == 'Standard',
title: 'Standard',
badge: 'EV-FIRST',
subtitle: 'Today by 6:00 PM',
note: '3-Wheeler Collection included',
price: '${draft.minPrice.toStringAsFixed(0)}',
icon: Icons.eco,
iconColor: AppColors.tertiary,
onTap: () {
draft.service = 'Standard';
state.touch();
},
),
const SizedBox(height: 12),
_ServiceCard(
active: draft.service == 'Express',
title: 'Express',
subtitle: 'Arrival in 2 hrs',
price: '${draft.maxPrice.toStringAsFixed(0)}',
icon: Icons.bolt,
iconColor: AppColors.secondary,
onTap: () {
draft.service = 'Express';
state.touch();
},
),
const SizedBox(height: 20),
// Selected slot
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.outlineVariant),
),
child: Row(
children: [
const Icon(Icons.calendar_month, color: AppColors.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('SELECTED SLOT',
style: AppTheme.caption
.copyWith(letterSpacing: 1, fontWeight: FontWeight.w600)),
Text('Pickup: ${draft.pickupDay}, ${draft.pickupSlot}',
style: AppTheme.labelMd),
],
),
),
],
),
),
const SizedBox(height: 16),
// Fare breakdown
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('ESTIMATED FARE',
style: AppTheme.caption.copyWith(
color: AppColors.onSurfaceVariant,
letterSpacing: 1.2,
fontWeight: FontWeight.w700)),
const SizedBox(height: 14),
_fareRow('Estimated Range', '${draft.minPrice.toStringAsFixed(0)} - ₹${draft.maxPrice.toStringAsFixed(0)}'),
const Divider(height: 24, color: AppColors.surfaceContainerHigh),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Final price determined at pickup', style: AppTheme.caption),
],
),
],
),
),
const SizedBox(height: 16),
// Pay-by row
AppCard(
onTap: () {},
child: Row(
children: [
const IconBadge(
icon: Icons.account_balance_wallet,
bg: AppColors.surfaceContainer,
color: AppColors.primary,
size: 36,
iconSize: 18,
circle: true,
),
const SizedBox(width: 12),
Expanded(child: Text('Pay by: UPI', style: AppTheme.labelMd)),
const Icon(Icons.expand_more, color: AppColors.onSurfaceVariant),
],
),
),
const SizedBox(height: 12),
const Row(
children: [
Icon(Icons.shield_outlined, size: 16, color: AppColors.tertiary),
SizedBox(width: 6),
Text('Secured by Doormile Pay Protection',
style: TextStyle(
fontSize: 12, color: AppColors.onSurfaceVariant)),
],
),
],
);
}
Widget _fareRow(String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
Text(value, style: AppTheme.labelMd),
],
),
);
}
class _ServiceCard extends StatelessWidget {
final bool active;
final String title;
final String? badge;
final String subtitle;
final String? note;
final String price;
final IconData icon;
final Color iconColor;
final VoidCallback onTap;
const _ServiceCard({
required this.active,
required this.title,
this.badge,
required this.subtitle,
this.note,
required this.price,
required this.icon,
required this.iconColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: active ? const Color(0xFFFBE3E6) : AppColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: active ? AppColors.primary : AppColors.outlineVariant,
width: active ? 2 : 1,
),
boxShadow: active ? null : AppTheme.cardShadow,
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: iconColor),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(title,
style: AppTheme.headlineSm, overflow: TextOverflow.ellipsis),
),
if (badge != null) ...[
const SizedBox(width: 8),
StatusChip(
label: badge!,
color: AppColors.tertiary,
bg: AppColors.tertiaryFixed.withValues(alpha: 0.5),
),
],
],
),
const SizedBox(height: 2),
Text(subtitle, style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
if (note != null)
Text(note!,
style: AppTheme.caption.copyWith(
color: AppColors.tertiary, fontWeight: FontWeight.w600)),
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (active)
const CircleAvatar(
radius: 11,
backgroundColor: AppColors.primary,
child: Icon(Icons.check, size: 14, color: Colors.white),
),
const SizedBox(height: 6),
Text(price,
style: AppTheme.headlineSm.copyWith(color: AppColors.primary)),
],
),
],
),
),
);
}
}