Miler rider app: surface system, visible design language, backend lifecycle
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>
This commit is contained in:
904
lib/views/Dashboard/pickups/shipment_capture.dart
Normal file
904
lib/views/Dashboard/pickups/shipment_capture.dart
Normal file
@@ -0,0 +1,904 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user