import 'dart:io'; 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:image_picker/image_picker.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; /// ───────────────────────────────────────────────────────────────────────── /// STOP VERIFICATION — proof of work at the door, one leg at a time. /// /// The old page had a single form with one boolean, `_isDelivery`, flipping a /// few labels. That boolean is `StopKind.isDelivery`, which is deliberately /// narrow — true only for a delivery-ONLY stop — so a COMBINED stop fell to /// the else branch and was verified as if it were a plain pickup: one photo, /// one "pickup code", and no evidence whatsoever that the outbound parcel was /// ever handed over. `StopKind.legs` existed to describe exactly this and had /// no caller anywhere in the app. /// /// So verification is now built on the legs, not on a boolean: /// /// pickup → Collect → Confirm /// delivery → Hand over → Confirm /// combined → Hand over → Collect → Confirm /// /// The rider parks once and works the door once, in that order — deliver /// first, because he walks up with the outbound parcel already in his hands. /// /// What each leg asks for follows Amazon Flex / Flipkart Wishmaster practice, /// trimmed to what this backend can actually carry: /// /// • **Hand over** — tick off each parcel as it leaves your hands, say who /// received it, OTP from the customer, photo. Ticking parcels individually /// is the one thing that stops a three-parcel drop being closed after two; /// a single "delivered" button cannot catch that. /// /// • **Collect** — count what you actually took (not what the manifest /// claims), flag the packaging condition, weight, photo, code. A count /// below the expected one demands a note: that discrepancy is the whole /// reason first-mile pickups get disputed later. /// /// The page returns the legacy result map the rest of the flow already reads /// (`verified` / `parcelImage` / `weight` / `otp` / `description` / /// `dropAddress`) plus a `delivery` and `pickup` sub-map, so `map.dart`, /// `homepage.dart` and `PaymentScreen` keep working untouched. /// ───────────────────────────────────────────────────────────────────────── class StopVerificationPage extends StatefulWidget { final Map pickup; const StopVerificationPage({super.key, required this.pickup}); @override State createState() => _StopVerificationPageState(); } /// Who actually took the parcel. Anything other than [customer] is a handover /// to a third party, which is where delivery disputes come from — so it forces /// a name and a photo. enum _Recipient { customer, family, security, neighbour } extension _RecipientUi on _Recipient { String get label => switch (this) { _Recipient.customer => 'Customer', _Recipient.family => 'Family', _Recipient.security => 'Security', _Recipient.neighbour => 'Neighbour', }; IconData get icon => switch (this) { _Recipient.customer => LucideIcons.user, _Recipient.family => LucideIcons.users, _Recipient.security => LucideIcons.shieldCheck, _Recipient.neighbour => LucideIcons.house, }; /// A third party took it → we need their name on record. bool get needsName => this != _Recipient.customer; String get wireName => name; } /// Condition of what is being collected. Recorded at the door because it can /// never be established afterwards. enum _Condition { sealed, unpacked, damaged } extension _ConditionUi on _Condition { String get label => switch (this) { _Condition.sealed => 'Sealed & intact', _Condition.unpacked => 'Not packed', _Condition.damaged => 'Damaged', }; IconData get icon => switch (this) { _Condition.sealed => LucideIcons.badgeCheck, // An open carton, not a stack of them: `boxes` drew three small squares // that read as a cluster of *something* at 16sp, which is the one thing a // condition glyph must not do. `packageOpen` says unsealed at a glance. _Condition.unpacked => LucideIcons.packageOpen, _Condition.damaged => LucideIcons.triangleAlert, }; Color get accent => switch (this) { _Condition.sealed => ColorConstants.acceptGreen, _Condition.unpacked => ColorConstants.warning, _Condition.damaged => ColorConstants.errorRed, }; /// Anything but a clean seal needs the rider to say what he saw. bool get needsNote => this != _Condition.sealed; String get wireName => name; } class _StopVerificationPageState extends State { // ── The stop ── late final StopKind _kind; late final List _legs; late final String _customerName; late final String _address; late final int _expectedDelivery; late final int _expectedPickup; late final double _cash; final ImagePicker _picker = ImagePicker(); /// Current step. When [_hasReview] the index `_legs.length` is the review /// step, so it is always last. int _step = 0; /// ── Whether this page ends with its own review ── /// /// It used to, always. On a **logistics** stop that is now one review too /// many: the shipment desk opens straight after this page and is followed by /// a review of its own that covers the whole order — parcel, addresses, /// weight, price and payment on one screen. Asking the rider to confirm the /// parcel, then describe the shipment, then confirm it all again is three /// screens for one doorstep and two of them look alike. /// /// So the page's own review is dropped exactly where a later one subsumes /// it, and kept where nothing follows — the line that verifies but does not /// price still needs a last look before it commits. bool get _hasReview => !ServiceProfile.active.capturesShipmentAddresses; /// The index of the last step the rider can be on. int get _lastStep => _hasReview ? _legs.length : _legs.length - 1; // ── Delivery leg ── final Set _handedOver = {}; _Recipient _recipient = _Recipient.customer; final TextEditingController _recipientName = TextEditingController(); final TextEditingController _deliveryOtp = TextEditingController(); File? _deliveryPhoto; // ── Pickup leg ── late int _collected; _Condition _condition = _Condition.sealed; final TextEditingController _conditionNote = TextEditingController(); final TextEditingController _weight = TextEditingController(); final TextEditingController _pickupCode = TextEditingController(); File? _pickupPhoto; bool _confirming = false; @override void initState() { super.initState(); final d = widget.pickup; _kind = stopKindOf(d); _legs = _kind.legs; _customerName = (d['pickupcustomer'] ?? d['PickupCustomer'] ?? 'Customer') .toString(); // ── The address of the door he is at, not the one he came from ── // // This read `pickupaddress` on every leg. On a delivery that is the // *sender's* address — where the parcel was collected — so the one line // naming the place the rider is standing was either wrong or, on a // delivery-only stop that carries no pickup address, the placeholder // "Stop location". A verification screen whose subject is a doorstep must // name that doorstep. String field(List keys) { for (final k in keys) { final v = (d[k] ?? '').toString().trim(); if (v.isNotEmpty && v != 'null') return v; } return ''; } final drop = field(['dropaddress', 'DropAddress', 'deliveryaddress']); final collect = field(['pickupaddress', 'PickupAddress']); _address = switch (_kind) { // A combined stop is one door: whichever address the row actually // carries describes it. StopKind.delivery => drop.isNotEmpty ? drop : collect, _ => collect.isNotEmpty ? collect : drop, }; _cash = stopCollectionAmount(d); // A leg that exists always has at least one parcel — a stop that hands // over nothing is not a delivery leg. _expectedDelivery = _kind.hasDelivery ? deliveryParcelCount(d).clamp(1, 99) : 0; _expectedPickup = _kind.hasPickup ? pickupParcelCount(d).clamp(1, 99) : 0; // Pre-set to the manifest count: the common case is that it matches, and // making the rider tap up from zero every time would train him to stop // reading the number. _collected = _expectedPickup; } @override void dispose() { _recipientName.dispose(); _deliveryOtp.dispose(); _conditionNote.dispose(); _weight.dispose(); _pickupCode.dispose(); super.dispose(); } // ── Completion rules ─────────────────────────────────────────────────── /// Every parcel ticked, a name when someone other than the customer took it, /// an OTP, and a photo. bool get _deliveryDone => _handedOver.length == _expectedDelivery && (!_recipient.needsName || _recipientName.text.trim().length >= 2) && _deliveryOtp.text.trim().length >= 4 && _deliveryPhoto != null; /// A short count or a bad seal must be explained; the photo is always /// required. The code is required on a pickup-only stop — on a combined stop /// the delivery OTP has already proved the rider was at the right door, and /// asking for a second code at the same doorstep is friction that buys /// nothing. /// /// ── Weight is required ── /// /// It was optional, on the reasoning that a rider without a scale should not /// be blocked. But weight is what the consignment is *billed* on: it leaves /// the door once, and if it is not captured here nobody can establish it /// afterwards — the parcel is already in the bag, then in a van, then in a /// sorting hub. A blank field silently pushed that dispute onto the office /// weeks later, and onto the rider who took it. bool get _pickupDone => _pickupPhoto != null && _weightEntered && (!_needsPickupNote || _conditionNote.text.trim().length >= 3) && (_kind.isCombined || _pickupCode.text.trim().length >= 4); /// A weight that is actually a weight: present, numeric and above zero. /// "0" is what gets typed to get past a required field, and it is not a /// measurement. bool get _weightEntered { final v = double.tryParse(_weight.text.trim()); return v != null && v > 0; } bool get _needsPickupNote => _condition.needsNote || _collected != _expectedPickup; bool _legDone(StopLeg leg) => leg.isDelivery ? _deliveryDone : _pickupDone; bool get _allDone => _legs.every(_legDone); /// What is missing on the step being shown, phrased as the next thing to do. String? get _blockingHint { if (_hasReview && _step >= _legs.length) { if (_allDone) return null; // Name the leg rather than saying "a step above" — on a combined stop // that is the difference between one tap back and two. final unfinished = _legs.firstWhere((l) => !_legDone(l)); return unfinished.isDelivery ? 'Go back — the hand-over is not finished' : 'Go back — the collection is not finished'; } if (_legs[_step].isDelivery) { if (_handedOver.length != _expectedDelivery) { final left = _expectedDelivery - _handedOver.length; // States the shortfall rather than issuing an instruction. "Tick 1 more // parcel as handed over" describes the control; "1 parcel still to hand // over" describes the doorstep, which is where the rider's attention is // and the only place he can fix it. return '$left ${left == 1 ? 'parcel' : 'parcels'} still to hand over'; } if (_recipient.needsName && _recipientName.text.trim().length < 2) { return 'Enter the name of the person who took it'; } if (_deliveryOtp.text.trim().length < 4) { return 'Enter the OTP from the customer'; } if (_deliveryPhoto == null) return 'Take a proof photo'; return null; } if (_pickupPhoto == null) return 'Take a photo of the parcels'; if (!_weightEntered) return 'Enter the total weight in kg'; if (_needsPickupNote && _conditionNote.text.trim().length < 3) { return _collected != _expectedPickup ? 'Say why the count does not match' : 'Describe the condition you found'; } if (!_kind.isCombined && _pickupCode.text.trim().length < 4) { return 'Enter the pickup code from the customer'; } return null; } // ── Actions ──────────────────────────────────────────────────────────── Future _capture({required bool forDelivery}) async { try { final shot = await _picker.pickImage( source: ImageSource.camera, imageQuality: 85, ); if (shot == null || !mounted) return; setState(() { if (forDelivery) { _deliveryPhoto = File(shot.path); } else { _pickupPhoto = File(shot.path); } }); } catch (_) { if (!mounted) return; _toast('Could not open the camera'); } } /// Every message on this screen goes through the one feedback shape. /// See [AppFeedback]. void _toast(String message) => AppFeedback.error(context, message); void _next() { final hint = _blockingHint; if (hint != null) { _toast(hint); return; } FocusScope.of(context).unfocus(); // Without a review step the last leg IS the commit, so advancing past it // confirms rather than walking onto a screen that no longer exists. if (!_hasReview && _step >= _legs.length - 1) { _confirm(); return; } setState(() => _step = (_step + 1).clamp(0, _lastStep)); } void _back() { FocusScope.of(context).unfocus(); if (_step == 0) { Navigator.pop(context); return; } setState(() => _step -= 1); } void _confirm() { if (!_allDone) { _toast(_blockingHint ?? 'Finish every step to confirm'); return; } setState(() => _confirming = true); // The proof image the rest of the flow uploads. On a combined stop that is // the pickup shot — it is what evidences the parcels now in the rider's // bag, which is the leg the backend records as 'picked'. final File? primaryPhoto = _pickupPhoto ?? _deliveryPhoto; final String primaryCode = _kind.hasDelivery ? _deliveryOtp.text.trim() : _pickupCode.text.trim(); Navigator.pop(context, { // Legacy contract — map.dart, homepage.dart and PaymentScreen read these. 'verified': true, 'parcelImage': primaryPhoto?.path, 'weight': _weight.text.trim(), 'description': _conditionNote.text.trim(), 'dropAddress': (widget.pickup['dropaddress'] ?? widget.pickup['DropAddress'] ?? '') .toString(), 'otp': primaryCode, // Leg-scoped detail. New consumers should read these rather than // guessing which leg the flat keys above came from. 'stopKind': _kind.wireName, if (_kind.hasDelivery) 'delivery': { 'parcels': _expectedDelivery, 'handedOver': _handedOver.length, 'receivedBy': _recipient.wireName, 'receivedByName': _recipient.needsName ? _recipientName.text.trim() : _customerName, 'otp': _deliveryOtp.text.trim(), 'photo': _deliveryPhoto?.path, }, if (_kind.hasPickup) 'pickup': { 'expected': _expectedPickup, 'collected': _collected, 'shortBy': _expectedPickup - _collected, 'condition': _condition.wireName, 'note': _conditionNote.text.trim(), 'weight': _weight.text.trim(), 'code': _pickupCode.text.trim(), 'photo': _pickupPhoto?.path, }, }); } // ── Build ────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { return PopScope( canPop: _step == 0, onPopInvokedWithResult: (didPop, _) { if (!didPop) _back(); }, // ── Grey page, white bands ── // // It was the other way round — a white page carrying tinted panels — and // that inversion was itself a fix for panels that would not separate. // Both readings work; this one is taken because of what it does to the // *width*. A tinted panel has to be inset from the page or its fill runs // into the screen edge, so every section sat inside a 20pt gutter and the // page spent 40 of its 390 points on two strips of white that said // nothing. A white band on a grey ground has no such problem: it can run // the full width, because the ground showing above and below it is what // separates one section from the next. // // So the sections are full-bleed now and the gutter lives *inside* them, // where it is padding around content rather than margin around a box. // Same air next to the words, ~40pt more of it for the words themselves — // which on this screen is the difference between an address on two lines // and an address on three. child: Scaffold( backgroundColor: ColorConstants.daylightSurface, // ── The bar breathes, and its title is a title ── // // 17sp jammed against the status bar is a system default, not a // decision: on a screen the rider opens at a doorstep with the phone at // arm's length, the one word telling him which job he is verifying was // the smallest text on it. Taller bar, type at 22, and the whole thing // pushed clear of the notch — the same proportions Uber's task screens // use, and for the same reason. // // The 22h top inset is the part that was missing. `toolbarHeight` alone // centres its content, so raising the bar moved the title *down by half* // what was added and left it sitting right under the clock — which is // where a system default puts it, and the reason this looked cramped // against every other page in the app. The inset pushes it the rest of // the way, to roughly where `appTopBar`'s heading lands on the Account // pages. appBar: milerPageBar(_title, onBack: _back), body: Column( children: [ _stepper(), Expanded( child: SingleChildScrollView( physics: const BouncingScrollPhysics(), // No horizontal padding: every child of this column is a // full-bleed band that carries its own. See the note on the // scaffold's ground. padding: EdgeInsets.only(bottom: 24.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _stopSummary(), // No rule under the summary any more: the steps below // are bands with the page's ground between them, and a // divider between two boundaries is a third boundary. if (_hasReview && _step >= _legs.length) ...[ SizedBox(height: 6.h), _reviewStep(), ] else if (_legs[_step].isDelivery) _deliveryStep() else _pickupStep(), ], ), ), ), _bottomBar(), ], ), ), ); } String get _title => switch (_kind) { StopKind.pickup => 'Verify pickup', StopKind.delivery => 'Verify delivery', StopKind.combined => 'Verify stop', }; // ── Stepper ──────────────────────────────────────────────────────────── // // Names the legs rather than numbering them. "Step 2 of 3" tells the rider // nothing he can act on; "Collect" tells him what he is at the door to do, // and on a combined stop it is the only thing on screen that says a second // job is still waiting after this one. Widget _stepper() { final labels = [ for (final leg in _legs) leg.isDelivery ? 'Hand over' : 'Collect', 'Confirm', ]; final accents = [ for (final leg in _legs) leg.accent, ColorConstants.acceptGreen, ]; return Container( width: double.infinity, decoration: BoxDecoration( color: ColorConstants.pureSurface, // Belongs to the bar above it, not to the form below. Without the rule // it read as a first section of the page, which put a progress display // — the one thing on screen the rider never touches — at the head of a // list of things he does. border: Border( bottom: BorderSide(color: ColorConstants.borderSubtle, width: 1), ), ), padding: EdgeInsets.fromLTRB(20.w, 4.h, 20.w, 11.h), // ── Every part gives ground ── // // The nodes used to take their natural width with only the connectors // flexible. Three named steps — "Hand over", "Collect", "Confirm" — are // wider than a 360pt phone from about 1.4x system text upward, so a // combined stop overflowed the row and struck the red-and-yellow bar // across the top of the page, on every step including the one the rider // is signing. Flexible nodes cost nothing at 1.0x, where each still takes // its natural width. child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: List.generate(labels.length * 2 - 1, (i) { if (i.isOdd) { final done = i ~/ 2 < _step; return Expanded( child: Container( height: 2.h, // The connector is drawn against the circle, not the label, so // it sits at the circle's mid-line rather than in the middle of // a node that may now be two lines tall. margin: EdgeInsets.fromLTRB(6.w, 11.5.w, 6.w, 0), color: done ? ColorConstants.acceptGreen : ColorConstants.borderSubtle, ), ); } final idx = i ~/ 2; final current = idx == _step; final past = idx < _step; final accent = accents[idx]; return Flexible( child: Column( mainAxisSize: MainAxisSize.min, children: [ AnimatedContainer( duration: const Duration(milliseconds: 220), width: 25.w, height: 25.w, alignment: Alignment.center, decoration: BoxDecoration( color: past ? ColorConstants.acceptGreen : current ? accent : ColorConstants.pureSurface, shape: BoxShape.circle, border: Border.all( color: past ? ColorConstants.acceptGreen : current ? accent : ColorConstants.borderStrong, width: 2, ), ), child: past ? Icon( LucideIcons.check, size: 15.sp, color: Colors.white, ) : Text( '${idx + 1}', style: TextStyle( fontSize: 11.5.sp, fontWeight: FontWeight.w700, color: current ? Colors.white : ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), SizedBox(height: 3.h), // Wraps rather than ellipsises: "Hand over" broken across two // lines still says what the step is. "Hand o…" does not, and a // step label the rider cannot read is the same as no stepper. Text( labels[idx], textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 10.5.sp, height: 1.2, fontWeight: current ? FontWeight.w700 : FontWeight.w500, color: past ? ColorConstants.acceptGreen : current ? accent : ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ], ), ); }), ), ); } // ── Stop summary ─────────────────────────────────────────────────────── // // Two different kinds of thing share this screen, and they were being drawn // at the same rank. The stop — who, where, how much — is *context*: the rider // read it once on the map screen, rode here on it, and is looking at the // building it names. The sections below are *work*. // // As a filled panel it looked like a fifth thing to fill in, and it was the // first thing on the page, so every leg opened by drawing the eye to the one // block that needs nothing done to it. Unboxed it reads as a heading, which // is what it is. /// The stop, as the band the form is being filled in *about*. /// /// Full-bleed white, like every section under it, so the page reads as one /// stack of bands rather than a heading floating over a stack of boxes. Widget _stopSummary() { return MilerBand( padding: EdgeInsets.fromLTRB(_bandPad, 16.h, _bandPad, 18.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Both halves give ground, neither is cut off ── // // A fixed badge, a `Spacer` and a fixed amount: the row was wider // than a 360pt phone from 1.3x system text upward, and the rider who // has turned his font up is the one who most needs to read "₹2450 to // collect". Flexible on both sides costs nothing at normal size — // each still takes its natural width — and lets the longer one // ellipsise instead of striping the card black and yellow. Row( children: [ // The stop type as a dotted label rather than a filled badge. It // is an attribute of the name below it, and a saturated chip made // it the loudest thing on a screen whose subject is the form. Container( width: 6.w, height: 6.w, margin: EdgeInsets.only(right: 8.w), decoration: BoxDecoration( color: _kind.accent, shape: BoxShape.circle, ), ), Flexible( child: Text( _kind.titleLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.sp, fontWeight: FontWeight.w700, color: _kind.accent, fontFamily: FontConstants.fontFamily, ), ), ), const Spacer(), if (_cash > 0) Flexible( child: Text( '₹${_cash.toStringAsFixed(0)} to collect', maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.right, style: TextStyle( fontSize: 12.sp, fontWeight: FontWeight.w700, color: ColorConstants.acceptGreen, fontFamily: FontConstants.fontFamily, ), ), ), ], ), SizedBox(height: 8.h), Text( _customerName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 19.sp, fontWeight: FontWeight.w700, letterSpacing: -0.5, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 5.h), // The pin is doing work, not decorating: this line and the customer // name above it are both grey-on-white running text at a glance, and // the glyph is what says which of the two is the place. Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.only(top: 2.h, right: 6.w), child: Icon( LucideIcons.mapPin, size: 14.sp, color: ColorConstants.secondaryText, ), ), Expanded( child: Text( _address, maxLines: 2, overflow: TextOverflow.ellipsis, style: MilerType.micro.copyWith( fontSize: 13.sp, height: 1.4, color: ColorConstants.onSurfaceVariant, ), ), ), ], ), SizedBox(height: 12.h), // Wrap rather than Row: on a combined stop the two pills together are // wider than the page once the system font is up, and the second one // belongs on its own line rather than off the edge of the screen. Wrap( spacing: 7.w, runSpacing: 7.h, children: [ if (_expectedDelivery > 0) _loadPill( LucideIcons.truck, 'Hand over $_expectedDelivery', ColorConstants.deliveryAccent, ), if (_expectedPickup > 0) _loadPill( LucideIcons.package, 'Collect $_expectedPickup', ColorConstants.pickupAccent, ), ], ), ], ), ); } /// A finished band's ground: the accept green at 5.5%, resolved onto white /// so it is an ordinary opaque colour rather than a wash that changes with /// what is behind it. static final Color _doneFill = ColorConstants.tint( ColorConstants.acceptGreen, 0.055, ); /// The inset a band keeps around its content — the app's own, so this page /// and the shipment desk it hands off to line up at the same left margin. double get _bandPad => MilerBand.pad; Widget _loadPill(IconData icon, String label, Color accent) { return Container( padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 6.h), decoration: BoxDecoration( color: ColorConstants.tint(accent, 0.10), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 13.sp, color: accent), SizedBox(width: 5.w), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5.sp, fontWeight: FontWeight.w700, color: accent, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ); } // ── Delivery leg ─────────────────────────────────────────────────────── Widget _deliveryStep() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _section( 'Hand over the parcels', // Counts up once there is more than one to count. On a three-parcel // drop "tick each one" is the instruction and "2 of 3 ticked" is the // progress, and the rider standing at the door needs the second more // than the first by the time he is half-way through. _expectedDelivery > 1 ? 'Tick each one as it leaves your hands · ' '${_handedOver.length} of $_expectedDelivery ticked' : 'Tick it as it leaves your hands', done: _handedOver.length == _expectedDelivery, icon: LucideIcons.package, child: Column( children: [ for (var i = 0; i < _expectedDelivery; i++) ...[ if (i > 0) SizedBox(height: 8.h), _parcelTile(i), ], ], ), ), _section( 'Who received it?', _recipient.needsName ? 'A third party took it — record their name' : null, done: !_recipient.needsName || _recipientName.text.trim().length >= 2, icon: LucideIcons.mapPinned, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _choiceGrid( count: _Recipient.values.length, chips: (width, lastWidth) => [ for (final (i, r) in _Recipient.values.indexed) _choiceChip( label: r.label, icon: r.icon, selected: _recipient == r, accent: ColorConstants.deliveryAccent, width: i == _Recipient.values.length - 1 ? lastWidth : width, onTap: () => setState(() => _recipient = r), ), ], ), if (_recipient.needsName) ...[ SizedBox(height: 12.h), _field( controller: _recipientName, hint: "Person's name", icon: LucideIcons.idCard, capitalization: TextCapitalization.words, ), ], ], ), ), _section( 'Delivery OTP', 'Ask the customer to read it out', done: _deliveryOtp.text.trim().length >= 4, icon: LucideIcons.grid3x3, child: _CodeBoxes( controller: _deliveryOtp, length: 6, accent: ColorConstants.deliveryAccent, onChanged: () => setState(() {}), ), ), _section( 'Proof photo', 'The parcel at the door, or with the person who took it', done: _deliveryPhoto != null, icon: LucideIcons.camera, child: _photoBox( file: _deliveryPhoto, accent: ColorConstants.deliveryAccent, onTap: () => _capture(forDelivery: true), onClear: () => setState(() => _deliveryPhoto = null), ), ), ], ); } Widget _parcelTile(int index) { final ticked = _handedOver.contains(index); return PressScale( onTap: () => setState(() { if (!_handedOver.remove(index)) _handedOver.add(index); }), child: AnimatedContainer( duration: const Duration(milliseconds: 160), padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h), decoration: BoxDecoration( color: ticked ? ColorConstants.tint(ColorConstants.acceptGreen, 0.10) : ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), // The tick is the whole point of this control, so the untouched state // has to look pressable. On the old grey panel the white fill did // that; on a white page only an edge can. border: Border.all( color: ticked ? ColorConstants.acceptGreen.withValues(alpha: 0.45) : ColorConstants.borderStrong, width: 1.4, ), ), child: Row( children: [ Icon( ticked ? LucideIcons.circleCheck : LucideIcons.circle, size: 21.sp, color: ticked ? ColorConstants.acceptGreen : ColorConstants.borderStrong, ), SizedBox(width: 10.w), Expanded( child: Text( 'Parcel ${index + 1} of $_expectedDelivery', maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w600, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), ), // Flexible, and after an `Expanded` that has already taken the // slack: a fixed trailing label plus a 21sp glyph is wider than // the tile at 2x text, and the tile is inside a panel now, which // is 32pt narrower than the page it used to sit on. if (ticked) ...[ SizedBox(width: 8.w), Flexible( child: Text( 'Handed over', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5.sp, fontWeight: FontWeight.w700, color: ColorConstants.acceptGreen, fontFamily: FontConstants.fontFamily, ), ), ), ], ], ), ), ); } // ── Pickup leg ───────────────────────────────────────────────────────── Widget _pickupStep() { final short = _collected != _expectedPickup; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _section( 'Parcels collected', 'Count what you actually took', done: true, icon: LucideIcons.shoppingCart, child: Column( children: [ _counter(), if (short) ...[ SizedBox(height: 11.h), _notice( _collected < _expectedPickup ? '${_expectedPickup - _collected} fewer than the ' '${_expectedPickup == 1 ? 'parcel' : 'parcels'} booked' : '${_collected - _expectedPickup} more than booked', ColorConstants.warning, ), ], ], ), ), _section( 'Condition', 'Recorded now — it cannot be proved later', done: !_condition.needsNote, icon: LucideIcons.badgeCheck, child: _choiceGrid( count: _Condition.values.length, chips: (width, lastWidth) => [ for (final (i, c) in _Condition.values.indexed) _choiceChip( label: c.label, icon: c.icon, selected: _condition == c, accent: c.accent, width: i == _Condition.values.length - 1 ? lastWidth : width, onTap: () => setState(() => _condition = c), ), ], ), ), if (_needsPickupNote) ...[ _section( 'What happened?', 'Required — this is what settles a dispute later', done: _conditionNote.text.trim().length >= 3, icon: LucideIcons.triangleAlert, child: _field( controller: _conditionNote, hint: _collected != _expectedPickup ? 'e.g. customer only had 2 ready' : 'e.g. corner of the box is crushed', icon: LucideIcons.stickyNote, maxLines: 3, capitalization: TextCapitalization.sentences, ), ), ], _section( 'Weight', 'The consignment is billed on this — it cannot be established later', done: _weightEntered, icon: LucideIcons.weight, child: _field( controller: _weight, hint: 'Total weight in kg', icon: LucideIcons.weight, keyboard: const TextInputType.numberWithOptions(decimal: true), formatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.]'))], ), ), _section( 'Parcel photo', 'All $_collected ${_collected == 1 ? 'parcel' : 'parcels'} in one shot', done: _pickupPhoto != null, icon: LucideIcons.camera, child: _photoBox( file: _pickupPhoto, accent: ColorConstants.pickupAccent, onTap: () => _capture(forDelivery: false), onClear: () => setState(() => _pickupPhoto = null), ), ), _section( 'Pickup code', _kind.isCombined ? 'Optional — the delivery OTP already verified this door' : 'Ask the customer to read it out', done: _kind.isCombined || _pickupCode.text.trim().length >= 4, icon: LucideIcons.grid3x3, optional: _kind.isCombined, child: _CodeBoxes( controller: _pickupCode, length: 6, accent: ColorConstants.pickupAccent, onChanged: () => setState(() {}), ), ), ], ); } Widget _counter() { Widget btn(IconData icon, VoidCallback? onTap) { final enabled = onTap != null; // ── The count is the subject; these two only adjust it ── // // Both buttons wore the leg accent — which on a pickup *is* the brand // red — as fill, rim and glyph. Two saturated brand-red controls framing // the figure meant the loudest thing in the panel was the pair of // nudges, not the number they nudge. // // Neutral chrome hands that back: the figure is the only ink with weight // in the row, and the red the screen does spend is left for the leg // itself and for the action bar that commits it. return PressScale( onTap: onTap, child: Container( width: 46.w, height: 46.w, decoration: BoxDecoration( color: enabled ? ColorConstants.pureSurface : ColorConstants.neutralLight, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), border: Border.all( color: enabled ? ColorConstants.borderStrong : ColorConstants.borderSubtle, width: 1.5, ), ), child: Icon( icon, size: 21.sp, color: enabled ? ColorConstants.slateText : ColorConstants.disabledFill, ), ), ); } return Row( children: [ btn( LucideIcons.minus, _collected > 0 ? () => setState(() => _collected -= 1) : null, ), Expanded( child: Column( children: [ Text( '$_collected', style: TextStyle( fontSize: 30.sp, fontWeight: FontWeight.w700, letterSpacing: -1, height: 1.1, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), Text( 'of $_expectedPickup booked', style: TextStyle( fontSize: 11.5.sp, fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ], ), ), btn( LucideIcons.plus, _collected < 99 ? () => setState(() => _collected += 1) : null, ), ], ); } // ── Review ───────────────────────────────────────────────────────────── // // ── Why this screen is not another form panel ── // // It used to be one more `_section` — same tinted card, same title row, same // "Required" marker — sitting under the same stop summary, so the last screen // of the flow looked exactly like the two the rider had just filled in. He // read it as another thing to do rather than as the thing he was signing. // // It is a statement. The heading says so in the page's own voice rather than // inside a card, the legs are separated so a combined stop reads as two // distinct pieces of work, and the photos he took are *shown* — "Photo ✓" is // not something anyone can check, and a black frame from a pocket-shot is the // commonest reason a proof is thrown out weeks later, by which time the // parcel is long gone. Widget _reviewStep() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // The heading is the screen talking rather than a section of it, but it // is still a band: with full-bleed sections under it, a heading inset // by its own margin is the one thing on the page whose left edge does // not line up with everything else. MilerBand( padding: EdgeInsets.fromLTRB(_bandPad, 16.h, _bandPad, 18.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( _allDone ? LucideIcons.clipboardCheck : LucideIcons.circleAlert, size: 22.sp, color: _allDone ? ColorConstants.acceptGreen : ColorConstants.warning, ), SizedBox(width: 9.w), Expanded( child: Text( _allDone ? 'Check before you confirm' : 'Something is missing', style: TextStyle( fontSize: 19.sp, fontWeight: FontWeight.w700, letterSpacing: -0.5, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), ), ], ), SizedBox(height: 5.h), Text( _allDone ? 'This is exactly what gets recorded against the stop.' : 'Go back and finish the step above — nothing is recorded ' 'until every part is done.', style: TextStyle( fontSize: 13.5.sp, height: 1.4, fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ], ), ), SizedBox(height: MilerBand.gap), if (_kind.hasDelivery) _reviewLeg( title: 'Handed over', accent: ColorConstants.deliveryAccent, icon: LucideIcons.truck, photo: _deliveryPhoto, done: _deliveryDone, facts: [ ReviewFact( 'Parcels', '${_handedOver.length} of $_expectedDelivery', icon: LucideIcons.boxes, ), ReviewFact( 'OTP', _deliveryOtp.text.trim(), icon: LucideIcons.lock, mono: true, ), ReviewFact( 'Received by', _recipient.needsName ? '${_recipientName.text.trim()} · ${_recipient.label}' : _customerName, icon: _recipient.icon, wide: true, ), ], ), if (_kind.hasDelivery && _kind.hasPickup) SizedBox(height: MilerBand.gap), if (_kind.hasPickup) _reviewLeg( title: 'Collected', accent: ColorConstants.pickupAccent, icon: LucideIcons.package, photo: _pickupPhoto, done: _pickupDone, facts: [ ReviewFact( 'Parcels', '$_collected of $_expectedPickup', icon: LucideIcons.package, ), ReviewFact( 'Weight', '${_weight.text.trim()} kg', icon: LucideIcons.weight, ), ReviewFact( 'Condition', _condition.label, icon: _condition.icon, tint: _condition.accent, ), if (!_kind.isCombined) ReviewFact( 'Pickup code', _pickupCode.text.trim(), icon: LucideIcons.pin, mono: true, ), if (_conditionNote.text.trim().isNotEmpty) ReviewFact( 'Note', _conditionNote.text.trim(), icon: LucideIcons.stickyNote, tint: ColorConstants.warning, wide: true, ), ], ), if (_cash > 0) ...[ SizedBox(height: MilerBand.gap), _notice( 'Collect ₹${_cash.toStringAsFixed(0)} from the customer on the ' 'next screen', ColorConstants.acceptGreen, ), ], ], ); } /// One leg of the stop, as a panel: what happened, and the photo proving it. /// /// The photo sits at the foot of its own leg rather than in a gallery of its /// own, so on a combined stop there is no question which shot belongs to the /// hand-over and which to the collection — the pairing is the whole reason /// both are taken. /// /// [done] draws the leg's own verdict on the leg. On a combined stop the /// page-level "Something is missing" says nothing about *which* of the two /// jobs is short, and that is exactly the case where the rider has to know: /// one tap back, or two. Widget _reviewLeg({ required String title, required Color accent, required IconData icon, required List facts, required bool done, File? photo, }) { // White, and full-bleed like every other band. It was a tinted rounded card, // which on a grey page is a box on a box: the ground between two bands // already says these are two legs of the stop. return MilerBand( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, size: 16.sp, color: accent), SizedBox(width: 7.w), Expanded( child: Text( title.toUpperCase(), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.sp, fontWeight: FontWeight.w700, letterSpacing: 0.8, color: accent, fontFamily: FontConstants.fontFamily, ), ), ), _legVerdict(done), ], ), SizedBox(height: 12.h), _factGrid(facts, accent), SizedBox(height: 10.h), _proofPhoto(photo, accent), ], ), ); } /// The leg's own tick, or the reason there isn't one. Widget _legVerdict(bool done) { // The same amber the page heading uses for "Something is missing". // ColorConstants.warning is a signal colour, not a text colour — at // 10sp on a 12% tint it is barely there in daylight. final color = done ? ColorConstants.acceptGreen : ColorConstants.warning; return Container( padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), decoration: BoxDecoration( color: ColorConstants.tint(color, 0.12), borderRadius: BorderRadius.circular(DesignConstants.radiusFull), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( done ? LucideIcons.circleCheck : LucideIcons.circleAlert, size: 12.sp, color: color, ), SizedBox(width: 4.w), Text( done ? 'Complete' : 'Unfinished', style: TextStyle( fontSize: 10.sp, fontWeight: FontWeight.w700, letterSpacing: 0.2, color: color, fontFamily: FontConstants.fontFamily, ), ), ], ), ); } /// The shot, as a strip you can open. /// /// It was a bare 130pt `cover` crop with a "Proof photo attached" caption /// underneath, and a crop is exactly where a bad photo hides: a thumb over the /// lens or a blown-out doorway lives at the edge of the frame that got cut. /// Giving the photo its true 4:3 shape fixed that and cost 250pt per leg — /// half a panel, twice over on a combined stop — on the one screen this file /// packs facts two-to-a-row specifically to keep short. /// /// So the strip stays compact and the full frame is one tap away. That is /// what the caption should have been all along: it spent a line telling the /// rider a photo existed, which he could see, instead of letting him check /// the thing that actually gets a proof thrown out weeks later. /// /// A missing photo says so rather than collapsing. The gate above should have /// caught it, but a confirm screen that renders one leg with a photo and the /// other with a silent gap is a confirm screen that lied about the second leg. Widget _proofPhoto(File? photo, Color accent) { if (photo == null) { return Container( width: double.infinity, padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 14.h), decoration: BoxDecoration( color: ColorConstants.tint(ColorConstants.warning, 0.10), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Row( children: [ Icon( LucideIcons.imageOff, size: 15.sp, color: ColorConstants.warning, ), SizedBox(width: 8.w), Expanded( child: Text( 'No proof photo — go back and take one', style: TextStyle( fontSize: 12.sp, fontWeight: FontWeight.w600, color: ColorConstants.warning, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ); } return PressScale( onTap: () => _openPhoto(photo), child: ClipRRect( borderRadius: BorderRadius.circular(DesignConstants.radiusLg), child: Stack( children: [ Image.file( photo, height: 118.h, width: double.infinity, fit: BoxFit.cover, errorBuilder: (_, _, _) => Container( height: 118.h, color: ColorConstants.pureSurface, alignment: Alignment.center, child: Icon( LucideIcons.imageOff, size: 22.sp, color: ColorConstants.secondaryText, ), ), ), // The chip rides on the image instead of taking a line under it. // Six facts, a photo and a caption did not fit above the fold, and // the caption is the one piece that was only ever repeating what the // picture already showed. Positioned( left: 8.w, bottom: 8.h, child: Container( padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.62), borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(LucideIcons.zoomIn, size: 13.sp, color: Colors.white), SizedBox(width: 5.w), Text( 'Tap to enlarge', style: TextStyle( fontSize: 10.sp, fontWeight: FontWeight.w700, color: Colors.white, fontFamily: FontConstants.fontFamily, ), ), ], ), ), ), ], ), ), ); } /// Full-frame view of a proof photo. Pinch-zoomable: a pickup code written on /// a label is legible in the original and not in a 4:3 thumbnail. void _openPhoto(File photo) { showDialog( context: context, barrierColor: Colors.black.withValues(alpha: 0.92), builder: (ctx) => Stack( children: [ Positioned.fill( child: InteractiveViewer( maxScale: 5, child: Center(child: Image.file(photo, fit: BoxFit.contain)), ), ), Positioned( top: 8.h, right: 8.w, child: SafeArea( child: IconButton( tooltip: 'Close', icon: Icon(LucideIcons.x, size: 26.sp, color: Colors.white), onPressed: () => Navigator.of(ctx).pop(), ), ), ), ], ), ); } /// The leg's facts, two to a row. /// /// ── Why a grid and not a list ── /// /// These were full-width rows: an icon, a caption and a value, stacked. Every /// one of them spent a whole line of a doorstep screen on a value that is /// almost always four characters — "3 of 3", "4.5 kg", "482156". Six of them /// ran past the fold, so the rider had to scroll a *confirmation* screen to /// see what he was confirming, which is the one screen that should fit. /// /// Paired up, the same six facts take three lines instead of six and can be /// read as a block rather than walked down. Values that are genuinely long — /// a recipient's name, a note about damage — say so with [_Fact.wide] and keep /// their own line, because wrapping those into half a column is how a grid /// turns back into a wall of text. /// ── Why the row is wrapped in an IntrinsicHeight ── /// /// It was a bare `Row(crossAxisAlignment: stretch)`. A Row's cross axis is /// vertical, and this whole step is inside a `SingleChildScrollView`, so the /// height it was told to stretch to was *infinity*. Every fact row threw /// "BoxConstraints forces an infinite height" during layout, the exception /// propagated up through the leg panel's Column, and the entire review — every /// figure the rider was being asked to confirm — rendered as nothing at all. /// The step only looked empty; it had actually failed. /// /// `IntrinsicHeight` gives the row a real height first — the taller of the two /// tiles — which is what `stretch` was always meant to match them to. Widget _factGrid(List facts, Color accent) { final rows = packReviewFacts(facts); if (rows.isEmpty) return const SizedBox.shrink(); return Column( children: [ for (var i = 0; i < rows.length; i++) Padding( // Gap between rows only. A trailing 8pt under the last row pushed // the photo out of true with the panel's own padding. padding: EdgeInsets.only(bottom: i == rows.length - 1 ? 0 : 8.h), child: IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded(child: _factTile(rows[i].first, accent)), if (rows[i].length > 1) ...[ SizedBox(width: 8.w), Expanded(child: _factTile(rows[i][1], accent)), ], ], ), ), ), ], ); } /// One fact: a caption, and the thing itself underneath at the size the /// rider is actually checking. Widget _factTile(ReviewFact fact, Color accent) { final tint = fact.tint ?? accent; return Container( padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h), decoration: BoxDecoration( color: ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Icon(fact.icon, size: 13.sp, color: tint), SizedBox(width: 6.w), Flexible( child: Text( fact.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.sp, fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), ], ), SizedBox(height: 4.h), Text( fact.value, maxLines: fact.wide ? 3 : 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.sp, height: 1.25, fontWeight: FontWeight.w700, letterSpacing: fact.mono ? 1.2 : -0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), ], ), ); } // ── Bottom bar ───────────────────────────────────────────────────────── // // Slide-to-confirm only on the last step. On the leg steps it is a plain // button: a slider on every screen turns the deliberate friction of the // final commit into a reflex the rider stops noticing. Widget _bottomBar() { final hint = _blockingHint; final onReview = _hasReview && _step >= _legs.length; return Container( padding: EdgeInsets.fromLTRB(14.w, 10.h, 14.w, 12.h), decoration: BoxDecoration( color: ColorConstants.pureSurface, border: Border( top: BorderSide(color: ColorConstants.borderSubtle, width: 1), ), boxShadow: DesignConstants.shadowSm, ), child: SafeArea( top: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (hint != null) ...[ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( LucideIcons.info, size: 14.sp, color: ColorConstants.secondaryText, ), SizedBox(width: 6.w), Flexible( child: Text( hint, textAlign: TextAlign.center, style: TextStyle( fontSize: 12.sp, fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), ], ), SizedBox(height: 9.h), ], if (onReview) _confirmSlider(hint == null) else if (hint != null) // Blocked. See [_blockedCta] — the bar goes colourless, and the // line above it says what is missing. _blockedCta(_forwardLabel) else MilerButton( // "Next · collect" reads as a step number with a word attached. // "Continue to collect" is the sentence the rider is already // half-way through, and on a combined stop it is the only thing // on screen saying a second job follows this one. label: _forwardLabel, icon: LucideIcons.arrowRight, // ── Green, on every step ── // // This took the next leg's accent, so the forward button was // blue on one step and green on the next inside a single stop. // The leg accent still identifies which job is running — it is // on the step indicator above, where identifying is its job — // but the button that advances the work is one colour from // Accept on Home to "Move to next stop" on the handoff. color: ColorConstants.acceptGreen, onPressed: _next, ), ], ), ), ); } /// What the forward button is called on the step being shown. /// /// On the last leg it names the screen that actually follows: this page's own /// review where it has one, and the shipment desk where the logistics flow /// takes over. A button reading "Review" that opens a form asking for /// addresses is the kind of small lie that makes a rider distrust the rest of /// the flow. String get _forwardLabel { if (_step == _legs.length - 1) { return _hasReview ? 'Review' : 'Continue to shipment'; } return 'Continue to ' '${_legs[_step + 1].isDelivery ? 'hand over' : 'collect'}'; } /// The action bar when the step is not finished. /// /// ── Why not just a disabled [MilerButton] ── /// /// [MilerButton] greys out by dropping the whole control to 45% opacity, and /// that is the wrong tool twice over here. A saturated accent at 45% on white /// is still unmistakably a blue (or maroon) button, so the rider taps it — and /// the label goes down with the fill, landing at roughly 1.4:1 against it, so /// the one control on the screen becomes the least readable thing on it. /// /// This is a different shape instead: a neutral fill with a lock and grey /// type at 6:1. Colourless means "not yet", the words stay readable, and the /// moment the leg is complete the bar turns a solid accent. That change — /// grey to colour — is the readiness signal, visible at arm's length without /// reading anything. Widget _blockedCta(String label) { return Container( height: ButtonSizes.primary, alignment: Alignment.center, decoration: BoxDecoration( color: ColorConstants.neutralLight, borderRadius: BorderRadius.circular(ButtonSizes.radius), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( LucideIcons.lock, size: 17.sp, color: ColorConstants.secondaryText, ), SizedBox(width: 7.w), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ); } Widget _confirmSlider(bool enabled) { if (_confirming) { return Container( height: ButtonSizes.primary, decoration: BoxDecoration( color: ColorConstants.acceptGreen, borderRadius: BorderRadius.circular(ButtonSizes.radius), ), child: const Center( child: SizedBox( width: 22, height: 22, child: CircularProgressIndicator( color: Colors.white, strokeWidth: 2, ), ), ), ); } if (!enabled) return _blockedCta('Complete every step'); // ── A button, not a slide ── // // This was a `SliderButton` reading "Slide to complete delivery", with a // dismiss threshold of 0.85 — the rider had to drag the knob across 85% of // the screen for it to fire. Riders tapped it. A tap on a slider does // nothing at all: no ripple, no error, no movement, no clue that a drag was // wanted. The whole delivery flow dead-ended on this control, one gesture // short of finishing, and the stop stayed open on the server. // // Every other terminal action in this app is a tap — the confirm sheet's // own note says "ACTION BUTTONS (tap, not swipe)" and the map screen says // "Slides are reserved for money". This one is not money; it is the end of // a four-step form the rider has already filled in, and the form itself is // the deliberation. So it is a button, labelled with what it does. return MilerButton( label: 'Confirm ${_kind.noun.toLowerCase()}', icon: LucideIcons.circleCheck, color: ColorConstants.acceptGreen, onPressed: _confirm, ); } // ── Shared pieces ────────────────────────────────────────────────────── /// One step of the form: a label, a line of help, and the control itself. /// /// ── Why this stopped being a card ── /// /// It was a filled, rounded, 16pt-padded panel, and there are four to six of /// them on every leg. Stacked, they turn the page into a column of grey slabs /// with white controls floating inside them — a *container* rank and a /// *control* rank competing at the same weight, when only one of them is /// something the rider touches. /// /// The panels were also doing nothing the page could not. A section is /// already separated from its neighbour by the gap between them; the fill was /// paying 32pt of padding and a whole background colour to restate it. What /// it cost was focus: the biggest, heaviest shapes on a screen of things to /// fill in were the boxes, not the fields. /// /// So the group is named and ruled off instead. The title drops to a small /// letterspaced grey label — the quietest way to name a group, and the same /// one the handoff screen uses — a hairline marks the change of subject, and /// everything left with weight on the page is something the rider acts on. /// /// The rule is drawn *above* each section, so the first one also separates /// the form from the stop summary heading it sits under. /// One step of the form, as a surface of its own. /// /// ── Why these are panels and not ruled paragraphs ── /// /// They were an eyebrow, a hairline and a child, stacked down a white page. /// That reads beautifully on a desk and badly at a gate: five steps with no /// edges are one long form, the rider's place in it is carried entirely by a /// 1px rule, and a section he has finished looks exactly like one he has not /// except for an 18px tick at the far right of the line. /// /// A panel per step fixes all three at once. Each has a boundary, so the /// screen is a short list of things to do rather than a scroll; each carries /// its own state in its **fill** — the one property visible from arm's /// length — so finished work recedes to a green wash and the step in front of /// him keeps the page's only accent; and each is titled at a size meant to be /// read outdoors instead of at eyebrow scale. /// /// The leading glyph is not decoration. Four of these steps are questions /// with near-identical shapes — tick things, pick one of four, type a name, /// take a photo — and the icon is what tells them apart before the title is /// read. /// The colour of the leg being verified. Falls back to the brand on the /// review step, which belongs to no single leg. Color get _legAccent => _step < _legs.length ? _legs[_step].accent : ColorConstants.primary; Widget _section( String title, String? subtitle, { required bool done, required Widget child, bool optional = false, IconData icon = LucideIcons.circleCheck, }) { // The leg's colour, not the brand's. Each leg of this flow is colour-coded // — a pickup is red, a delivery is blue — and the panels were painting // their glyphs brand-red on a page whose every other accent was blue, // which reads as two unrelated things on one screen. final accent = done ? ColorConstants.acceptGreen : _legAccent; return Padding( padding: EdgeInsets.only(top: MilerBand.gap), // ── State in the fill, and the fill is a colour rather than a wash ── // // Done recedes and outstanding keeps the page; that is the right property // to carry it, because a fill is the one thing readable at arm's length // in sun. Both states are opaque colours rather than the accent laid over // whatever is behind (see [ColorConstants.tint]), and the finished band // adds the rail, which is what replaced the tinted outline these sections // used to close with. See [MilerBand]. child: MilerBand( color: done ? _doneFill : ColorConstants.pureSurface, rail: done ? ColorConstants.acceptGreen : null, padding: EdgeInsets.fromLTRB(_bandPad, 15.h, _bandPad, 17.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 30.w, height: 30.w, alignment: Alignment.center, decoration: BoxDecoration( color: ColorConstants.tint( accent, 0.11, on: done ? _doneFill : ColorConstants.pureSurface, ), borderRadius: BorderRadius.circular( DesignConstants.radiusLg, ), ), child: Icon( done ? LucideIcons.check : icon, size: 16.sp, color: accent, ), ), SizedBox(width: 11.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( title, style: MilerType.body.copyWith( fontSize: 15.5.sp, fontWeight: FontWeight.w700, letterSpacing: -0.25, ), ), if (subtitle != null) ...[ SizedBox(height: 3.h), Text( subtitle, style: MilerType.micro.copyWith(height: 1.35), ), ], ], ), ), SizedBox(width: 10.w), Padding( padding: EdgeInsets.only(top: 4.h), child: _sectionMarker(done: done, optional: optional), ), ], ), SizedBox(height: 13.h), child, ], ), ), ); } /// Whether this step is satisfied — and it means *this step*, not the screen. /// /// The tick is scoped to the section it sits on, which is the only reading /// that survives a rider glancing at "Who received it? ✓" while the OTP below /// is still empty. The bottom bar is what speaks for the screen as a whole, /// and it names the one thing still missing. Widget _sectionMarker({required bool done, required bool optional}) { if (optional) { return Text( 'Optional', style: TextStyle( fontSize: 11.sp, fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ); } // The panel's own glyph already carries the tick, and its fill already // says finished — a second green mark on the same line is the third time // one fact is stated. if (done) return const SizedBox.shrink(); // ── Outstanding is amber, because that is what amber is for ── // // This wore `_legAccent`, so on a pickup it was brand red — the same ink // as the header, the leg dot and the commit button. Red therefore said // "this is Miler", "this is a pickup" and "you still owe me this" at once, // and a word repeated three ways stops being read. // // Amber is the app's declared ink for something genuinely owed (see // DESIGN_SYSTEM §4) and it is spent nowhere else on this screen, so the // markers now separate from the page instead of blending into it. return Container( padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), decoration: BoxDecoration( color: ColorConstants.tint(ColorConstants.warning, 0.10), borderRadius: BorderRadius.circular(DesignConstants.radiusFull), ), child: Text( 'Required', style: TextStyle( fontSize: 9.5.sp, fontWeight: FontWeight.w700, letterSpacing: 0.3, color: ColorConstants.warning, fontFamily: FontConstants.fontFamily, ), ), ); } /// The recipient / condition pickers, as an even two-column grid. /// /// These were natural-width chips in a `Wrap`, which packed them by accident: /// "Customer | Family | Security" on one line and "Neighbour" orphaned on the /// next, three widths in four controls. A ragged grid of mixed-width blocks is /// read one item at a time; an even one is read as a set, which is what a /// four-way choice is. /// /// Half the row each, so two rows of two. The width comes from a /// [LayoutBuilder] rather than `Expanded`, because these still live in a /// `Wrap` — a chip whose own label is taller than one line has to be able to /// grow downward without dragging its neighbour with it. /// /// [count] is passed so an odd number can be handled: three conditions in a /// two-up grid leaves the third at half width on a line of its own, which /// looks like the grid ran out rather than like a row. The last one takes the /// full width instead, and the block reads as two deliberate rows. Widget _choiceGrid({ required int count, required List Function(double width, double lastWidth) chips, }) { return LayoutBuilder( builder: (context, constraints) { const gap = 9.0; final full = constraints.maxWidth; final half = (full - gap) / 2; return Wrap( spacing: gap, runSpacing: gap, children: chips(half, count.isOdd ? full : half), ); }, ); } Widget _choiceChip({ required String label, required IconData icon, required bool selected, required Color accent, required VoidCallback onTap, double? width, }) { return SizedBox( width: width, child: PressScale( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 160), padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h), // ── One height, whatever the label does ── // // These sit in a two-up grid, and "Sealed & intact" wraps to two // lines where "Not packed" does not — so one row of the grid stood a // line taller than the other and the set stopped reading as a set. // A floor, not a fixed height: at large system text a label that // genuinely needs three lines still gets them. constraints: BoxConstraints(minHeight: 52.h), alignment: Alignment.centerLeft, decoration: BoxDecoration( color: selected ? accent : ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), // An edge at rest. On the old grey panel a white chip separated // itself by fill alone; on a white page it has nothing to separate // it from, and an unselected option that reads as plain text is an // option the rider does not know he can press. border: Border.all( color: selected ? accent : ColorConstants.borderStrong, width: 1.4, ), ), child: Row( // ── Tight when the width is given, loose when it is not ── // // A chip in the grid is handed an exact width, so its label must // take *exactly* what is left after the glyph — `Flexible` only // caps a child at the space available and lets it keep its // minimum intrinsic width, which at 2x text is wider than the // chip and struck the page with an overflow band. mainAxisSize: width == null ? MainAxisSize.min : MainAxisSize.max, children: [ Icon( icon, size: 16.sp, color: selected ? Colors.white : ColorConstants.secondaryText, ), SizedBox(width: 7.w), // Flexible, not a bare Text. A [Wrap] hands every chip the full page // width to measure against, so a chip is only ever as wide as its // own label — and at a large system font "Sealed & intact" is wider // than the page, which overflowed instead of wrapping. Invisible at // normal text size; the only thing standing between a rider with big // type and a black-and-yellow band across his condition picker. () { final text = Text( label, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w600, color: selected ? Colors.white : ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ); return width == null ? Flexible(child: text) : Expanded(child: text); }(), ], ), ), ), ); } Widget _field({ required TextEditingController controller, required String hint, required IconData icon, int maxLines = 1, TextInputType? keyboard, List? formatters, TextCapitalization capitalization = TextCapitalization.none, }) { return TextField( controller: controller, maxLines: maxLines, keyboardType: keyboard, inputFormatters: formatters, textCapitalization: capitalization, onChanged: (_) => setState(() {}), style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w500, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), decoration: InputDecoration( hintText: hint, hintStyle: TextStyle( fontSize: 13.5.sp, fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), prefixIcon: Icon( icon, size: 18.sp, color: ColorConstants.secondaryText, ), filled: true, fillColor: ColorConstants.pureSurface, contentPadding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 13.h), // Outlined at rest, with the accent spent on the field the cursor is // in. This was borderless, which worked while the fill sat on a grey // panel and said "type here" by contrast alone — on a white page a // white borderless field is not a field. border: OutlineInputBorder( borderRadius: BorderRadius.circular(DesignConstants.radiusLg), borderSide: BorderSide( color: ColorConstants.borderStrong, width: 1.4, ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(DesignConstants.radiusLg), borderSide: BorderSide( color: ColorConstants.borderStrong, width: 1.4, ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(DesignConstants.radiusLg), borderSide: BorderSide(color: ColorConstants.primary, width: 1.6), ), ), ); } Widget _photoBox({ required File? file, required Color accent, required VoidCallback onTap, required VoidCallback onClear, }) { if (file != null) { return Column( children: [ ClipRRect( borderRadius: BorderRadius.circular(DesignConstants.radiusLg), child: Image.file( file, height: 160.h, width: double.infinity, fit: BoxFit.cover, ), ), SizedBox(height: 9.h), Row( children: [ Expanded( child: MilerButton( label: 'Retake', icon: LucideIcons.rotateCw, variant: MilerButtonVariant.outlined, color: ColorConstants.slateText, height: ButtonSizes.compact, onPressed: onTap, ), ), SizedBox(width: 9.w), Expanded( child: MilerButton( label: 'Remove', icon: LucideIcons.trash2, variant: MilerButtonVariant.outlined, color: ColorConstants.errorRed, height: ButtonSizes.compact, onPressed: onClear, ), ), ], ), ], ); } return PressScale( onTap: onTap, child: Container( // Padded, not a fixed height. `.h` scales with screen HEIGHT and `.sp` // with width, so a fixed-height box around sp-sized content overflows // on a short, wide screen — or on any phone with the system font // scaled up. Letting it size to its content cannot. padding: EdgeInsets.symmetric(vertical: 30.h), width: double.infinity, decoration: BoxDecoration( color: ColorConstants.tint(accent, 0.06), borderRadius: BorderRadius.circular(DesignConstants.radiusXl), border: Border.all(color: accent.withValues(alpha: 0.30), width: 1.4), ), child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(LucideIcons.camera, size: 27.sp, color: accent), SizedBox(height: 7.h), Text( 'Take photo', style: TextStyle( fontSize: 13.5.sp, fontWeight: FontWeight.w700, color: accent, fontFamily: FontConstants.fontFamily, ), ), ], ), ), ); } Widget _notice(String text, Color accent) { return Container( width: double.infinity, margin: EdgeInsets.symmetric(horizontal: _bandPad), padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h), decoration: BoxDecoration( color: ColorConstants.tint( accent, 0.10, on: ColorConstants.daylightSurface, ), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Row( children: [ Icon(LucideIcons.info, size: 16.sp, color: accent), SizedBox(width: 8.w), Expanded( child: Text( text, style: TextStyle( fontSize: 12.sp, height: 1.35, fontWeight: FontWeight.w600, color: accent, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ); } } /// Packs review facts into rows of one or two. /// /// Top-level and pure so the rule can be asserted directly: the review step /// sits behind a camera gate no widget test can pass, and "does the grid pair /// things correctly" is the part worth holding. /// /// A wide fact takes the row it starts on; narrow ones pair off around it. /// Order is meaning here — parcels before condition, weight before the note — /// so nothing is re-sorted to make the packing tidier. List> packReviewFacts(List facts) { final live = facts.where((f) => f.value.trim().isNotEmpty).toList(); final rows = >[]; var i = 0; while (i < live.length) { final first = live[i]; final canPair = !first.wide && i + 1 < live.length && !live[i + 1].wide; rows.add(canPair ? [first, live[i + 1]] : [first]); i += canPair ? 2 : 1; } return rows; } /// One thing the review screen is asserting about the stop. /// /// A value plus how to draw it, rather than a pre-built widget, so [_factGrid] /// can decide the packing — which facts pair off and which take a row — without /// the caller having to know the layout. class ReviewFact { final String label; final String value; final IconData icon; /// Takes its own row. For values that are sentences rather than figures. final bool wide; /// Codes read digit by digit, so they are tracked out. final bool mono; /// Overrides the leg's accent — a damaged condition is amber wherever it /// appears, not the pickup maroon. final Color? tint; const ReviewFact( this.label, this.value, { required this.icon, this.wide = false, this.mono = false, this.tint, }); } /// Boxed digit entry, the shape every Indian delivery app uses for an OTP. /// /// One real [TextField] sits invisibly behind the boxes and owns focus and the /// keyboard; the boxes just draw its characters. Building it out of six real /// fields means six focus nodes and hand-rolled backspace behaviour, which is /// where these widgets usually break. class _CodeBoxes extends StatefulWidget { final TextEditingController controller; final int length; final Color accent; final VoidCallback onChanged; const _CodeBoxes({ required this.controller, required this.length, required this.accent, required this.onChanged, }); @override State<_CodeBoxes> createState() => _CodeBoxesState(); } class _CodeBoxesState extends State<_CodeBoxes> { final FocusNode _focus = FocusNode(); @override void initState() { super.initState(); _focus.addListener(() => setState(() {})); } @override void dispose() { _focus.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final text = widget.controller.text; return Stack( children: [ // The invisible input. Kept in the tree (not Offstage) so the platform // keyboard and autofill still attach to it. SizedBox( height: 48.h, child: Opacity( opacity: 0, child: TextField( controller: widget.controller, focusNode: _focus, keyboardType: TextInputType.number, maxLength: widget.length, inputFormatters: [FilteringTextInputFormatter.digitsOnly], showCursor: false, enableInteractiveSelection: false, // maxLength draws a "0/6" counter by default, which would make // this invisible field taller than the boxes drawn over it and // overflow the row. decoration: const InputDecoration( counterText: '', border: InputBorder.none, contentPadding: EdgeInsets.zero, ), onChanged: (_) { setState(() {}); widget.onChanged(); }, ), ), ), Positioned.fill( child: GestureDetector( onTap: () => _focus.requestFocus(), behavior: HitTestBehavior.opaque, child: Row( children: List.generate(widget.length, (i) { final filled = i < text.length; final active = _focus.hasFocus && i == text.length; return Expanded( child: Container( height: 48.h, margin: EdgeInsets.symmetric( horizontal: MilerCodeBox.gap(context).w / 2, ), alignment: Alignment.center, // The same spec the login code uses — see [MilerCodeBox]. // The two screens keep different input models (this one // draws over a single hidden field so paste works; auth // uses one controller per box so SMS autofill works) but // the rider meets one control. decoration: MilerCodeBox.decoration( focused: active, filled: filled, hasError: false, accent: widget.accent, ), child: Text( filled ? text[i] : '', style: MilerCodeBox.digit(widget.accent), ), ), ); }), ), ), ), ], ); } }