Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
905 lines
31 KiB
Dart
905 lines
31 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
import 'package:geolocator/geolocator.dart';
|
||
|
||
import 'package:miler/data/miler_api.dart';
|
||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// THE SHIPMENT DESK — what a logistics rider settles at the customer's door.
|
||
///
|
||
/// A logistics booking usually arrives half-known. The customer raised it from
|
||
/// a map pin and described the destination as a phone number and a landmark;
|
||
/// nobody has weighed anything. The rider standing in the sender's doorway is
|
||
/// the first person who can establish any of it, and everything downstream —
|
||
/// which hub it routes through, what it costs, what the customer pays now —
|
||
/// follows from what he writes here.
|
||
///
|
||
/// ```
|
||
/// FROM ──▶ TO ──▶ WEIGHT ──▶ DISTANCE ──▶ PRICE ──▶ (payment, then initiate)
|
||
/// ```
|
||
///
|
||
/// ── Why the four steps are on one screen ──
|
||
///
|
||
/// They are not four decisions; they are one conversation, held once, standing
|
||
/// up. A wizard would make the rider commit to an address before he has been
|
||
/// told the weight, and re-pricing after a correction would mean walking back
|
||
/// through pages. Here every field feeds the same quote, and the quote
|
||
/// re-computes when any of them changes.
|
||
///
|
||
/// ── What this screen does NOT do ──
|
||
///
|
||
/// It does not take money and it does not initiate anything. It hands its
|
||
/// capture back to the caller, which sends the addresses, then the parcel
|
||
/// measurements, then the payment, and only then `pickup-complete`. That order
|
||
/// is the contract's, not a preference: `pickup-complete` converts the booking
|
||
/// into a consignment and reads the addresses to route it, so anything written
|
||
/// after it has nowhere to land.
|
||
///
|
||
/// ── And it never invents a fare ──
|
||
///
|
||
/// The price comes from `POST /pricing/check` against the real pincodes and the
|
||
/// real weight. When the table has no rule for a combination the screen says so
|
||
/// and refuses to continue, because the alternative — a locally computed number
|
||
/// that looks official on a doorstep — is how a rider ends up collecting the
|
||
/// wrong money in the company's name.
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
class ShipmentCapturePage extends StatefulWidget {
|
||
final Map<String, dynamic> pickup;
|
||
|
||
const ShipmentCapturePage({super.key, required this.pickup});
|
||
|
||
@override
|
||
State<ShipmentCapturePage> createState() => _ShipmentCapturePageState();
|
||
}
|
||
|
||
class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
|
||
// ── FROM ──
|
||
final _fromAddress = TextEditingController();
|
||
final _fromPincode = TextEditingController();
|
||
|
||
// ── TO ──
|
||
final _toAddress = TextEditingController();
|
||
final _toPincode = TextEditingController();
|
||
final _toCity = TextEditingController();
|
||
|
||
// ── The shipment ──
|
||
final _weight = TextEditingController();
|
||
String _serviceType = 'Normal';
|
||
String _category = 'General';
|
||
|
||
/// The rider's fix at the door, so the FROM pin is where he is standing
|
||
/// rather than where the customer dropped a marker last week.
|
||
double? _fromLat;
|
||
double? _fromLon;
|
||
|
||
ShipmentQuote? _quote;
|
||
bool _quoting = false;
|
||
String? _quoteError;
|
||
|
||
/// Debounces the quote: the weight field re-fires on every keystroke and
|
||
/// "1", "1.", "1.2" are three requests for one number.
|
||
Timer? _quoteDebounce;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final d = widget.pickup;
|
||
|
||
// Seeded from the booking so the rider is confirming, not transcribing.
|
||
// Most of the time the customer's address is right and he changes nothing.
|
||
_fromAddress.text = (d['pickupaddress'] ?? d['PickupAddress'] ?? '')
|
||
.toString();
|
||
_fromPincode.text = (d['pickuppincode'] ?? '').toString();
|
||
_toAddress.text = (d['dropaddress'] ?? d['DropAddress'] ?? '').toString();
|
||
_toPincode.text = (d['droppincode'] ?? '').toString();
|
||
|
||
_fromLat = double.tryParse('${d['pickuplat'] ?? ''}');
|
||
_fromLon = double.tryParse('${d['pickuplon'] ?? ''}');
|
||
|
||
_captureRiderPosition();
|
||
|
||
for (final c in [_fromPincode, _toPincode, _weight]) {
|
||
c.addListener(_scheduleQuote);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_quoteDebounce?.cancel();
|
||
for (final c in [
|
||
_fromAddress,
|
||
_fromPincode,
|
||
_toAddress,
|
||
_toPincode,
|
||
_toCity,
|
||
_weight,
|
||
]) {
|
||
c.dispose();
|
||
}
|
||
super.dispose();
|
||
}
|
||
|
||
/// The FROM pin, taken where the rider is standing.
|
||
///
|
||
/// Best-effort: a failed fix leaves the booking's own coordinates in place,
|
||
/// which is what the app had before. Never blocks the form.
|
||
Future<void> _captureRiderPosition() async {
|
||
try {
|
||
final pos = await Geolocator.getCurrentPosition(
|
||
locationSettings: const LocationSettings(
|
||
accuracy: LocationAccuracy.high,
|
||
timeLimit: Duration(seconds: 6),
|
||
),
|
||
).timeout(const Duration(seconds: 7));
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_fromLat = pos.latitude;
|
||
_fromLon = pos.longitude;
|
||
});
|
||
} catch (_) {
|
||
// Keep whatever the booking carried.
|
||
}
|
||
}
|
||
|
||
double get _weightKg => double.tryParse(_weight.text.trim()) ?? 0;
|
||
|
||
/// Pincodes are what the server derives the zone from, so a quote is
|
||
/// impossible without both — and a six-digit one is the only shape that can
|
||
/// be one.
|
||
bool get _canQuote =>
|
||
_weightKg > 0 &&
|
||
_fromPincode.text.trim().length == 6 &&
|
||
_toPincode.text.trim().length == 6;
|
||
|
||
void _scheduleQuote() {
|
||
_quoteDebounce?.cancel();
|
||
if (!_canQuote) {
|
||
if (_quote != null || _quoteError != null) {
|
||
setState(() {
|
||
_quote = null;
|
||
_quoteError = null;
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
_quoteDebounce = Timer(const Duration(milliseconds: 600), _fetchQuote);
|
||
}
|
||
|
||
Future<void> _fetchQuote() async {
|
||
if (!_canQuote || _quoting) return;
|
||
setState(() {
|
||
_quoting = true;
|
||
_quoteError = null;
|
||
});
|
||
|
||
final res = await MilerApi.checkPrice(
|
||
weight: _weightKg,
|
||
serviceType: _serviceType,
|
||
pickupPincode: _fromPincode.text.trim(),
|
||
deliveryPincode: _toPincode.text.trim(),
|
||
category: _category,
|
||
);
|
||
if (!mounted) return;
|
||
|
||
if (!res.ok) {
|
||
setState(() {
|
||
_quoting = false;
|
||
_quote = null;
|
||
// The network failed, which is a "try again" — distinct from the table
|
||
// having no rule, which is not.
|
||
_quoteError = res.message.isEmpty
|
||
? 'Could not reach pricing. Check your connection and retry.'
|
||
: res.message;
|
||
});
|
||
return;
|
||
}
|
||
|
||
final quote = ShipmentQuote.fromData(res.map, preferredCategory: _category);
|
||
setState(() {
|
||
_quoting = false;
|
||
_quote = quote.found ? quote : null;
|
||
_quoteError = quote.found
|
||
? null
|
||
: 'No price is configured for this weight and route. '
|
||
'Call the hub before collecting.';
|
||
});
|
||
}
|
||
|
||
/// ── The straight-line distance between the two ends ──
|
||
///
|
||
/// Shown, not charged. The fare comes from the pricing table's zone, which
|
||
/// the server derives from the pincodes; this is the sanity check that tells
|
||
/// a rider he has typed a destination pincode from the wrong city before he
|
||
/// takes money for it. Labelled as approximate for exactly that reason —
|
||
/// claiming a road distance the app has not measured would be a worse lie
|
||
/// than showing none.
|
||
double? get _approxKm {
|
||
final toLat = double.tryParse('${widget.pickup['droplat'] ?? ''}');
|
||
final toLon = double.tryParse('${widget.pickup['droplon'] ?? ''}');
|
||
if (_fromLat == null || _fromLon == null) return null;
|
||
if (toLat == null || toLon == null || toLat == 0 || toLon == 0) return null;
|
||
return Geolocator.distanceBetween(_fromLat!, _fromLon!, toLat, toLon) /
|
||
1000.0;
|
||
}
|
||
|
||
bool get _isComplete =>
|
||
_fromAddress.text.trim().isNotEmpty &&
|
||
_fromPincode.text.trim().length == 6 &&
|
||
_toAddress.text.trim().isNotEmpty &&
|
||
_toPincode.text.trim().length == 6 &&
|
||
_weightKg > 0 &&
|
||
_quote != null;
|
||
|
||
/// What is still missing, named. A disabled button with no reason is the
|
||
/// thing riders phone the hub about.
|
||
String get _blockingReason {
|
||
if (_fromAddress.text.trim().isEmpty) return 'Confirm the pickup address';
|
||
if (_fromPincode.text.trim().length != 6) return 'Pickup pincode needed';
|
||
if (_toAddress.text.trim().isEmpty) return 'Enter the delivery address';
|
||
if (_toPincode.text.trim().length != 6) return 'Delivery pincode needed';
|
||
if (_weightKg <= 0) return 'Weigh the parcel';
|
||
if (_quoting) return 'Getting the price…';
|
||
if (_quoteError != null) return _quoteError!;
|
||
return 'Waiting for the price';
|
||
}
|
||
|
||
void _submit() {
|
||
if (!_isComplete) {
|
||
AppFeedback.info(context, _blockingReason);
|
||
return;
|
||
}
|
||
final q = _quote!;
|
||
Navigator.pop(context, <String, dynamic>{
|
||
'captured': true,
|
||
'from': {
|
||
'address': _fromAddress.text.trim(),
|
||
'pincode': _fromPincode.text.trim(),
|
||
'lat': _fromLat,
|
||
'lon': _fromLon,
|
||
},
|
||
'to': {
|
||
'address': _toAddress.text.trim(),
|
||
'pincode': _toPincode.text.trim(),
|
||
'city': _toCity.text.trim(),
|
||
'lat': widget.pickup['droplat'],
|
||
'lon': widget.pickup['droplon'],
|
||
},
|
||
'weight': _weightKg,
|
||
'category': _category,
|
||
'servicetype': _serviceType,
|
||
'zone': q.zone,
|
||
// The figure the customer is about to be asked for. The payment screen
|
||
// takes it from here so the two can never disagree about the amount.
|
||
'price': q.payable,
|
||
'pricemin': q.minPrice,
|
||
'pricemax': q.maxPrice,
|
||
'currency': q.currency,
|
||
'approxkm': _approxKm,
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: ColorConstants.daylightSurface,
|
||
appBar: milerPageBar('Shipment details'),
|
||
body: SafeArea(
|
||
child: Column(
|
||
children: [
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
physics: const BouncingScrollPhysics(),
|
||
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 24.h),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_journeyCard(),
|
||
SizedBox(height: 14.h),
|
||
_parcelCard(),
|
||
SizedBox(height: 14.h),
|
||
_quotePanel(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
Padding(
|
||
padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 12.h),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (!_isComplete)
|
||
Padding(
|
||
padding: EdgeInsets.only(bottom: 8.h),
|
||
child: Text(
|
||
_blockingReason,
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 11.5.sp,
|
||
fontWeight: FontWeight.w500,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
),
|
||
PrimaryButton(
|
||
label: _quote == null
|
||
? 'Continue to payment'
|
||
: 'Continue · ${_quote!.currency} '
|
||
'${_quote!.payable.toStringAsFixed(0)}',
|
||
onPressed: _isComplete ? _submit : null,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────
|
||
/// THE JOURNEY, AS ONE OBJECT
|
||
///
|
||
/// From and To were two headed groups of loose white fields on a grey page —
|
||
/// address, pincode, address, pincode, city — five pills with nothing
|
||
/// relating them. The rider reading it back to a customer had to hold the
|
||
/// pairing in his head, and the one fact the screen is actually about, that
|
||
/// this parcel goes from *here* to *there*, was never drawn.
|
||
///
|
||
/// So it is drawn: one surface, one rail, a dot at the origin and a pin at
|
||
/// the destination, with each end's fields grouped under its own marker.
|
||
/// This is the shape every logistics app in the world uses for a journey,
|
||
/// and it is the shape because it is read in one glance.
|
||
/// ─────────────────────────────────────────────────────────────────────
|
||
Widget _journeyCard() {
|
||
return Container(
|
||
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 16.h),
|
||
decoration: BoxDecoration(
|
||
color: ColorConstants.pureSurface,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
border: Border.all(color: ColorConstants.borderSubtle, width: 1.2),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_leg(
|
||
marker: _RouteMarker.origin,
|
||
label: 'FROM',
|
||
children: [
|
||
_field(
|
||
controller: _fromAddress,
|
||
hint: 'Pickup address',
|
||
icon: LucideIcons.house,
|
||
maxLines: 2,
|
||
),
|
||
SizedBox(height: 8.h),
|
||
_field(
|
||
controller: _fromPincode,
|
||
hint: 'Pincode',
|
||
icon: LucideIcons.mailbox,
|
||
keyboardType: TextInputType.number,
|
||
maxLength: 6,
|
||
digitsOnly: true,
|
||
),
|
||
],
|
||
),
|
||
_leg(
|
||
marker: _RouteMarker.destination,
|
||
label: 'TO',
|
||
children: [
|
||
_field(
|
||
controller: _toAddress,
|
||
hint: 'Delivery address',
|
||
icon: LucideIcons.mapPin,
|
||
maxLines: 2,
|
||
),
|
||
SizedBox(height: 8.h),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _field(
|
||
controller: _toPincode,
|
||
hint: 'Pincode',
|
||
icon: LucideIcons.mailbox,
|
||
keyboardType: TextInputType.number,
|
||
maxLength: 6,
|
||
digitsOnly: true,
|
||
),
|
||
),
|
||
SizedBox(width: 8.w),
|
||
Expanded(
|
||
child: _field(
|
||
controller: _toCity,
|
||
hint: 'City',
|
||
icon: LucideIcons.building2,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// One end of the journey: its marker, its name, its fields.
|
||
///
|
||
/// The rail is drawn by the marker column rather than behind the row, so it
|
||
/// stretches to whatever height the fields take — an address that wraps to
|
||
/// two lines lengthens the line instead of breaking it.
|
||
Widget _leg({
|
||
required _RouteMarker marker,
|
||
required String label,
|
||
required List<Widget> children,
|
||
}) {
|
||
final origin = marker == _RouteMarker.origin;
|
||
final tint = origin
|
||
? ColorConstants.pickupAccent
|
||
: ColorConstants.deliveryAccent;
|
||
|
||
return IntrinsicHeight(
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(
|
||
width: 26.w,
|
||
child: Column(
|
||
children: [
|
||
SizedBox(height: 3.h),
|
||
origin
|
||
? Container(
|
||
width: 11.w,
|
||
height: 11.w,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: tint, width: 3),
|
||
),
|
||
)
|
||
: Icon(LucideIcons.mapPin, size: 15.sp, color: tint),
|
||
if (origin)
|
||
Expanded(
|
||
child: Container(
|
||
width: 2,
|
||
margin: EdgeInsets.symmetric(vertical: 4.h),
|
||
color: ColorConstants.borderStrong,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Padding(
|
||
padding: EdgeInsets.only(bottom: origin ? 14.h : 0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontSize: 10.5.sp,
|
||
fontWeight: FontWeight.w800,
|
||
letterSpacing: 1.2,
|
||
color: tint,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
SizedBox(height: 8.h),
|
||
...children,
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// What is being sent — weight, what it is, how fast.
|
||
///
|
||
/// One surface rather than a heading and three loose blocks, for the same
|
||
/// reason as the journey above: these three answers are one description of
|
||
/// one parcel, and the price is computed from all three together.
|
||
Widget _parcelCard() {
|
||
return Container(
|
||
padding: EdgeInsets.fromLTRB(14.w, 15.h, 14.w, 16.h),
|
||
decoration: BoxDecoration(
|
||
color: ColorConstants.pureSurface,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
border: Border.all(color: ColorConstants.borderSubtle, width: 1.2),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
LucideIcons.package,
|
||
size: 15.sp,
|
||
color: ColorConstants.secondaryText,
|
||
),
|
||
SizedBox(width: 7.w),
|
||
Text(
|
||
'THE PARCEL',
|
||
style: TextStyle(
|
||
fontSize: 10.5.sp,
|
||
fontWeight: FontWeight.w800,
|
||
letterSpacing: 1.2,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
SizedBox(height: 12.h),
|
||
_field(
|
||
controller: _weight,
|
||
hint: 'Weight (kg)',
|
||
icon: LucideIcons.weight,
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
),
|
||
SizedBox(height: 12.h),
|
||
_chips(
|
||
label: 'Category',
|
||
values: MilerApi.parcelCategories,
|
||
selected: _category,
|
||
onSelect: (v) {
|
||
setState(() => _category = v);
|
||
_scheduleQuote();
|
||
},
|
||
),
|
||
SizedBox(height: 12.h),
|
||
_chips(
|
||
label: 'Service',
|
||
values: MilerApi.serviceTypes,
|
||
selected: _serviceType,
|
||
onSelect: (v) {
|
||
setState(() => _serviceType = v);
|
||
_scheduleQuote();
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// The number the rider says out loud, at the size he can read it at.
|
||
///
|
||
/// ── Why the total is not a table row ──
|
||
///
|
||
/// This was four `label · value` rows of equal weight — distance, zone,
|
||
/// chargeable weight, price — with the price differing only by a bold. But
|
||
/// three of those exist to *explain* the fourth, and the fourth is the only
|
||
/// one that gets spoken to a customer. Read at arm's length on a doorstep,
|
||
/// a bolded 13sp among three 13sp rows is not a total.
|
||
///
|
||
/// So the money leads at display size, on the accent, and the workings sit
|
||
/// under it as the supporting detail they are.
|
||
Widget _quotePanel() {
|
||
final km = _approxKm;
|
||
final quote = _quote;
|
||
final priced = quote != null && !_quoting && _quoteError == null;
|
||
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 16.h),
|
||
decoration: BoxDecoration(
|
||
color: priced
|
||
? ColorConstants.acceptGreen.withValues(alpha: 0.06)
|
||
: ColorConstants.pureSurface,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
border: Border.all(
|
||
color: priced
|
||
? ColorConstants.acceptGreen.withValues(alpha: 0.28)
|
||
: ColorConstants.borderSubtle,
|
||
width: 1.2,
|
||
),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
LucideIcons.receiptText,
|
||
size: 15.sp,
|
||
color: ColorConstants.secondaryText,
|
||
),
|
||
SizedBox(width: 7.w),
|
||
Text(
|
||
'WHAT THE CUSTOMER PAYS',
|
||
style: TextStyle(
|
||
fontSize: 10.5.sp,
|
||
fontWeight: FontWeight.w800,
|
||
letterSpacing: 1.2,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
SizedBox(height: 12.h),
|
||
if (priced) ...[
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||
textBaseline: TextBaseline.alphabetic,
|
||
children: [
|
||
Text(
|
||
quote.currency,
|
||
style: TextStyle(
|
||
fontSize: 17.sp,
|
||
fontWeight: FontWeight.w700,
|
||
color: ColorConstants.acceptGreen,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
SizedBox(width: 3.w),
|
||
Flexible(
|
||
child: Text(
|
||
quote.payable.toStringAsFixed(0),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 34.sp,
|
||
height: 1.05,
|
||
fontWeight: FontWeight.w800,
|
||
letterSpacing: -1.2,
|
||
color: ColorConstants.acceptGreen,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (quote.isRange) ...[
|
||
SizedBox(height: 2.h),
|
||
Text(
|
||
'Band ${quote.minPrice.toStringAsFixed(0)}–'
|
||
'${quote.maxPrice.toStringAsFixed(0)} · charging the lower',
|
||
style: TextStyle(
|
||
fontSize: 11.5.sp,
|
||
fontWeight: FontWeight.w600,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
],
|
||
SizedBox(height: 12.h),
|
||
Container(height: 1, color: ColorConstants.borderSubtle),
|
||
SizedBox(height: 10.h),
|
||
_row('Zone', quote.zone),
|
||
SizedBox(height: 6.h),
|
||
_row('Chargeable weight', '${quote.weight} kg'),
|
||
SizedBox(height: 6.h),
|
||
],
|
||
_row(
|
||
'Distance',
|
||
km == null ? 'Not available' : '≈ ${km.toStringAsFixed(1)} km',
|
||
// Said out loud rather than left for the rider to assume: it is a
|
||
// straight line between two pins, not a road distance, and the
|
||
// fare does not come from it.
|
||
note: km == null ? null : 'straight line — the fare uses the zone',
|
||
),
|
||
if (!priced) SizedBox(height: 12.h),
|
||
if (_quoting)
|
||
Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 14.w,
|
||
height: 14.w,
|
||
child: const CircularProgressIndicator(strokeWidth: 2),
|
||
),
|
||
SizedBox(width: 10.w),
|
||
Text(
|
||
'Getting the price…',
|
||
style: TextStyle(
|
||
fontSize: 12.5.sp,
|
||
fontWeight: FontWeight.w500,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
],
|
||
)
|
||
else if (_quoteError != null)
|
||
InfoBanner(
|
||
icon: LucideIcons.circleAlert,
|
||
text: _quoteError!,
|
||
color: ColorConstants.errorRed,
|
||
)
|
||
else if (_quote == null)
|
||
Text(
|
||
'Enter both pincodes and the weight to get a price.',
|
||
style: TextStyle(
|
||
fontSize: 12.5.sp,
|
||
fontWeight: FontWeight.w500,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _row(
|
||
String label,
|
||
String value, {
|
||
bool emphasis = false,
|
||
String? note,
|
||
}) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontSize: 12.5.sp,
|
||
fontWeight: FontWeight.w500,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
),
|
||
Text(
|
||
value,
|
||
style: TextStyle(
|
||
fontSize: emphasis ? 17.sp : 13.sp,
|
||
fontWeight: emphasis ? FontWeight.w800 : FontWeight.w600,
|
||
color: emphasis
|
||
? ColorConstants.primary
|
||
: ColorConstants.slateText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (note != null)
|
||
Padding(
|
||
padding: EdgeInsets.only(top: 2.h),
|
||
child: Text(
|
||
note,
|
||
style: TextStyle(
|
||
fontSize: 10.5.sp,
|
||
fontWeight: FontWeight.w500,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _field({
|
||
required TextEditingController controller,
|
||
required String hint,
|
||
required IconData icon,
|
||
TextInputType? keyboardType,
|
||
int maxLines = 1,
|
||
int? maxLength,
|
||
bool digitsOnly = false,
|
||
}) {
|
||
return TextField(
|
||
controller: controller,
|
||
keyboardType: keyboardType,
|
||
maxLines: maxLines,
|
||
maxLength: maxLength,
|
||
inputFormatters: digitsOnly
|
||
? [FilteringTextInputFormatter.digitsOnly]
|
||
: null,
|
||
onChanged: (_) => setState(() {}),
|
||
style: TextStyle(
|
||
fontSize: 13.5.sp,
|
||
fontWeight: FontWeight.w500,
|
||
color: ColorConstants.slateText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
decoration: InputDecoration(
|
||
counterText: '',
|
||
hintText: hint,
|
||
prefixIcon: Icon(icon, size: 18.sp),
|
||
filled: true,
|
||
fillColor: ColorConstants.pureSurface,
|
||
hintStyle: TextStyle(
|
||
fontSize: 13.sp,
|
||
fontWeight: FontWeight.w500,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
contentPadding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||
borderSide: BorderSide(color: ColorConstants.borderSubtle),
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||
borderSide: BorderSide(color: ColorConstants.borderSubtle),
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||
borderSide: BorderSide(color: ColorConstants.primary, width: 1.4),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _chips({
|
||
required String label,
|
||
required List<String> values,
|
||
required String selected,
|
||
required ValueChanged<String> onSelect,
|
||
}) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontSize: 11.5.sp,
|
||
fontWeight: FontWeight.w600,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
SizedBox(height: 6.h),
|
||
Wrap(
|
||
spacing: 6.w,
|
||
runSpacing: 6.h,
|
||
children: [
|
||
for (final v in values)
|
||
GestureDetector(
|
||
onTap: () => onSelect(v),
|
||
child: Container(
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: 12.w,
|
||
vertical: 7.h,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: v == selected
|
||
? ColorConstants.primary
|
||
: ColorConstants.pureSurface,
|
||
borderRadius: BorderRadius.circular(
|
||
DesignConstants.radiusFull,
|
||
),
|
||
border: Border.all(
|
||
color: v == selected
|
||
? ColorConstants.primary
|
||
: ColorConstants.borderSubtle,
|
||
),
|
||
),
|
||
child: Text(
|
||
v,
|
||
style: TextStyle(
|
||
fontSize: 12.sp,
|
||
fontWeight: FontWeight.w600,
|
||
color: v == selected
|
||
? ColorConstants.onAccent
|
||
: ColorConstants.slateText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Which end of a journey a marker draws.
|
||
enum _RouteMarker { origin, destination }
|