Files
doormile_milderapp/lib/views/Dashboard/pickups/stop_verify.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

2498 lines
93 KiB
Dart

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<String, dynamic> pickup;
const StopVerificationPage({super.key, required this.pickup});
@override
State<StopVerificationPage> 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<StopVerificationPage> {
// ── The stop ──
late final StopKind _kind;
late final List<StopLeg> _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<int> _handedOver = <int>{};
_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<String> 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<void> _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, <String, dynamic>{
// 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': <String, dynamic>{
'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': <String, dynamic>{
'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();
},
// ── White page, tinted panels ──
//
// Dropping the borders left the sections floating: a white card on a
// near-white page has no edge at all, so five panels read as one long
// undifferentiated form. Inverting it is what the Home stop card already
// does — the page is white and the card carries the fill — and it is the
// only way to separate surfaces without drawing a line around each one.
// See [ColorConstants.cardSurface] for why the fill sits 9.4% off white
// rather than the ~3% a "subtle" grey would give: this screen is filled
// in outdoors.
child: Scaffold(
backgroundColor: ColorConstants.pureSurface,
// ── 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(),
padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_stopSummary(),
// No rule under the summary any more: the steps below
// are panels with their own edges, and a divider between a
// card and a card is a line drawn between two boundaries
// that already exist.
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 = <String>[
for (final leg in _legs) leg.isDelivery ? 'Hand over' : 'Collect',
'Confirm',
];
final accents = <Color>[
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.
Widget _stopSummary() {
return 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,
),
],
),
],
);
}
Widget _loadPill(IconData icon, String label, Color accent) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 6.h),
decoration: BoxDecoration(
color: accent.withValues(alpha: 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.acceptGreen.withValues(alpha: 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 lives on the page, not in a card — this is the screen
// talking, not a section of it.
Padding(
padding: EdgeInsets.fromLTRB(4.w, 4.h, 4.w, 0),
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: 14.h),
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: 12.h),
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: 12.h),
_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<ReviewFact> facts,
required bool done,
File? photo,
}) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(16.r),
decoration: BoxDecoration(
color: ColorConstants.cardSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
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: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
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.warning.withValues(alpha: 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<void>(
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<ReviewFact> 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: 14.h),
child: AnimatedContainer(
duration: DesignConstants.motionState,
curve: Curves.easeOut,
width: double.infinity,
padding: EdgeInsets.fromLTRB(16.w, 15.h, 16.w, 16.h),
decoration: BoxDecoration(
// Done recedes; outstanding keeps the page. Neither is a border
// colour — a filled state survives sunlight and a 1px edge does not.
color: done
? ColorConstants.acceptGreen.withValues(alpha: 0.055)
: ColorConstants.daylightSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(
color: done
? ColorConstants.acceptGreen.withValues(alpha: 0.22)
: ColorConstants.borderSubtle,
width: 1.2,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 30.w,
height: 30.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.11),
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.warning.withValues(alpha: 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<Widget> 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<TextInputFormatter>? 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: accent.withValues(alpha: 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,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.10),
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<List<ReviewFact>> packReviewFacts(List<ReviewFact> facts) {
final live = facts.where((f) => f.value.trim().isNotEmpty).toList();
final rows = <List<ReviewFact>>[];
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),
),
),
);
}),
),
),
),
],
);
}
}