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 pickup; const ShipmentCapturePage({super.key, required this.pickup}); @override State createState() => _ShipmentCapturePageState(); } class _ShipmentCapturePageState extends State { // ── 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 _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 _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, { '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 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 values, required String selected, required ValueChanged 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 }