Initial commit
This commit is contained in:
843
lib/features/booking/screens/booking_pickup_screen.dart
Normal file
843
lib/features/booking/screens/booking_pickup_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
252
lib/features/booking/screens/booking_preview_screen.dart
Normal file
252
lib/features/booking/screens/booking_preview_screen.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
147
lib/features/booking/screens/booking_scaffold.dart
Normal file
147
lib/features/booking/screens/booking_scaffold.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
113
lib/features/booking/screens/booking_success_screen.dart
Normal file
113
lib/features/booking/screens/booking_success_screen.dart
Normal 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. We’ll 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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
349
lib/features/booking/screens/parcel_details_screen.dart
Normal file
349
lib/features/booking/screens/parcel_details_screen.dart
Normal 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 ['0–1 kg', '1–5 kg', '5–10 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
165
lib/features/booking/screens/payment_sheet.dart
Normal file
165
lib/features/booking/screens/payment_sheet.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
253
lib/features/booking/screens/service_price_screen.dart
Normal file
253
lib/features/booking/screens/service_price_screen.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user