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>
2498 lines
93 KiB
Dart
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),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|