production

This commit is contained in:
2026-08-28 11:13:15 +05:30
parent d7348e253f
commit 5723d373b2
162 changed files with 17924 additions and 7026 deletions

View File

@@ -816,12 +816,23 @@ class _CallButton extends StatelessWidget {
/// the kitchen's. See [StopContact].
final String? semanticLabel;
const _CallButton({required this.phone, this.size, this.semanticLabel});
/// A disc rather than a rounded square. The sheets draw it beside a round
/// avatar, where a square tile reads as a control belonging to something
/// else; the cards keep the square, which matches the tiles around it.
final bool round;
const _CallButton({
required this.phone,
this.size,
this.semanticLabel,
this.round = false,
});
@override
Widget build(BuildContext context) {
return _SquareIconButton(
icon: LucideIcons.phone,
round: round,
size: size ?? math.max(52.0, 52.w),
// ── Green, and the same green everywhere a phone appears ──
//
@@ -833,9 +844,9 @@ class _CallButton extends StatelessWidget {
// app he already uses — it is what the glyph means before the palette
// gets a say — and "done" is carried by a tick and a word, not by a hue
// on a handset.
background: ColorConstants.acceptGreen.withValues(alpha: 0.12),
background: Narrative.emeraldWash,
border: Colors.transparent,
iconColor: ColorConstants.acceptGreen,
iconColor: Narrative.emerald,
semanticLabel: semanticLabel ?? 'Call this stop',
onTap: () async {
// A dialer that fails to open is silent by default: the rider is at
@@ -859,6 +870,7 @@ class _SquareIconButton extends StatelessWidget {
final Color iconColor;
final String semanticLabel;
final VoidCallback onTap;
final bool round;
const _SquareIconButton({
required this.icon,
@@ -868,25 +880,29 @@ class _SquareIconButton extends StatelessWidget {
required this.iconColor,
required this.semanticLabel,
required this.onTap,
this.round = false,
});
@override
Widget build(BuildContext context) {
final radius = BorderRadius.circular(
round ? DesignConstants.radiusFull : DesignConstants.radiusXl,
);
return Semantics(
button: true,
label: semanticLabel,
child: Material(
color: background,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
borderRadius: radius,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
borderRadius: radius,
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
borderRadius: radius,
border: Border.all(color: border, width: 1.5),
),
child: Icon(icon, color: iconColor, size: 22.sp),
@@ -1044,71 +1060,21 @@ class _QueueHeading extends StatelessWidget {
),
),
],
if (order != null) ...[
const Spacer(),
Flexible(child: _OrderProvenance(source: order!)),
],
// ── The provenance chip is gone ──
//
// `As assigned` / `Nearest first` sat at the head of the queue, in a
// tinted pill with a glyph, telling the rider where the *ordering* of
// his list came from. It is a fact about this app's sorting, not
// about his work: he cannot act on it, he cannot change it, and on
// the common day it said the same three words every time he opened
// the tab. What he needs from that row is which trip and how far
// through it he is, and both are already on it.
],
),
);
}
}
/// `Hub route` / `Nearest first` — where the queue's order came from.
///
/// Deliberately quiet. It is a provenance mark, not a warning: nearest-first
/// is a reasonable order when nobody has assigned one, and the rider does not
/// need alarming about it. What he needs is to not be told it is his route.
class _OrderProvenance extends StatelessWidget {
final RouteOrderSource source;
const _OrderProvenance({required this.source});
@override
Widget build(BuildContext context) {
final assigned = source.isAdmin;
final tint = assigned
? ColorConstants.acceptGreen
: ColorConstants.secondaryText;
return Tooltip(
message: source.explanation,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
decoration: BoxDecoration(
color: tint.withValues(alpha: assigned ? 0.10 : 0.07),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
assigned ? LucideIcons.route : LucideIcons.navigation,
size: 11.sp,
color: tint,
),
SizedBox(width: 4.w),
Flexible(
child: Text(
source.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: tint,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
);
}
}
/// `Bag 1 · Joe` — the pairing, as one object.
///
/// ── Why they are one line and not two facts ──

View File

@@ -322,9 +322,10 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
if (_due <= 0) return _nothingToCollect();
return Scaffold(
// White page, tinted panels — the same inversion the verification screens
// it follows now use. See the note there.
backgroundColor: ColorConstants.pureSurface,
// Grey page, white bands — the same shape the verification screens this
// follows now take, so the money step reads as the next page of one flow
// rather than a different screen. See [MilerBand].
backgroundColor: ColorConstants.daylightSurface,
// Same bar as the verification screens this follows: taller, clear of
// the notch, title at a size that survives sunlight and arm's length.
appBar: milerPageBar(
@@ -336,18 +337,19 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(14.w, 14.h, 14.w, 24.h),
// The bands carry the gutter. See [MilerBand].
padding: EdgeInsets.only(bottom: 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_amountCard(),
SizedBox(height: 14.h),
SizedBox(height: MilerBand.gap),
_orderCard(),
SizedBox(height: 14.h),
SizedBox(height: MilerBand.gap),
_methodCard(),
SizedBox(height: 14.h),
SizedBox(height: MilerBand.gap),
_shortCard(),
SizedBox(height: 14.h),
SizedBox(height: MilerBand.gap),
_custodyNote(),
],
),
@@ -366,13 +368,8 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
// door, and a number he can edit is a number he will be argued into
// changing.
Widget _amountCard() {
return Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(18.w, 20.h, 18.w, 18.h),
decoration: BoxDecoration(
color: ColorConstants.cardSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
return MilerBand(
padding: EdgeInsets.fromLTRB(MilerBand.pad, 20.h, MilerBand.pad, 18.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -822,13 +819,10 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
// once, plainly, at the moment he takes it.
Widget _custodyNote() {
final onBehalf = _shipper.isNotEmpty ? _shipper : 'the shipper';
return Container(
width: double.infinity,
padding: EdgeInsets.all(13.r),
decoration: BoxDecoration(
color: ColorConstants.tertiary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
return MilerBand(
color: ColorConstants.tint(ColorConstants.tertiary, 0.08),
rail: ColorConstants.tertiary,
padding: EdgeInsets.fromLTRB(MilerBand.pad, 13.h, MilerBand.pad, 13.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -837,7 +831,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
Expanded(
child: Text(
'This money belongs to $onBehalf, not to you and not to Miler. '
'Deposit it at the hub at the end of your shift.',
'Hand it in at the end of your shift.',
style: TextStyle(
fontSize: 12.sp,
height: 1.4,
@@ -1049,13 +1043,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
// ── Shared pieces ──────────────────────────────────────────────────────
Widget _card({required String title, required Widget child}) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(16.r),
decoration: BoxDecoration(
color: ColorConstants.cardSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
return MilerBand(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@@ -214,25 +214,142 @@ Future<String> resolveConsignmentId(Map<String, dynamic> stop) async {
/// (it left `Collected_By_Miler` some other way) and a lingering
/// `IDEMPOTENCY_IN_PROGRESS` after [MilerApi] has waited the write out.
/// Neither is answerable from the error itself.
/// Why the last [releaseForDelivery] said no, in the rider's words.
///
/// ── One message was covering four different problems ──
///
/// Every failure below returned a bare `false`, and the caller printed *"This
/// parcel isn't ready to go out yet. Check your connection and try again."* for
/// all of them. Three of the four have nothing to do with a connection, and
/// two of them cannot be fixed by trying again at all:
///
/// • **No consignment.** The pickup never converted, or its id was lost. The
/// rider can retry this until his battery dies.
/// • **At a hub.** A cross-city parcel is `Inwarded_at_Hub` and will be
/// delivered by somebody else. There is nothing here for him to start.
/// • **Refused.** The server gave a reason and it was thrown away.
/// • **Unreachable.** The only one where "check your connection" is true.
///
/// Telling a rider to check a working connection, about a parcel that is not
/// his to deliver, is how a screen teaches him to ignore what it says. Null
/// after a success.
String? lastReleaseFailure;
/// Whether a "somebody else has this" hold is even possible on this rider's
/// line.
///
/// ── The state that cannot happen, refusing the rider anyway ──
///
/// `Inwarded_at_Hub`, `Tripsheet_Loaded` and `In_Transit` are the logistics
/// network's own rungs: a cross-city parcel is dropped at a collection point
/// and delivered by a different rider on a different day. That is a real hold
/// and it must keep blocking on the logistics line.
///
/// On a meal run **it cannot happen at all.** Every DailyGrubs order is
/// hyperlocal — collected at the kitchen and carried straight to the door, one
/// rider, one leg, no network in between. So one of those states coming back
/// for a meal stop is not news about the food; it is a bad read, and the two
/// ways it happens are both known: a consignment reference that resolved to
/// the wrong record, and a row whose state belongs to a different booking.
///
/// The rider was shown the hold anyway, mid-round, on a slider whose only job
/// is to set him off — and there was nothing he could do about it, because the
/// thing it described was not true of his stop.
///
/// So a hold is only believed on a line that has somewhere to hold things. On
/// every other line the state is treated as unreadable, which sends the
/// question to the server: it is the judge of its own consignment, and one
/// refusal carrying its own reason beats a client-side refusal inventing one.
bool get _handoffHoldIsPossible => ServiceProfile.active.endsAtHub;
Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
lastReleaseFailure = null;
final consignmentId = await resolveConsignmentId(stop);
if (consignmentId.isEmpty) {
debugPrint('[MILKRUN] no consignment id for ${MilkRun.idOf(stop)}');
lastReleaseFailure =
"This stop has no delivery reference yet, so it can't be started. "
'Ask your office to check it — sliding again will not help.';
return false;
}
// What the list row already told us, before spending a request on it.
var state = consignmentStateFromRaw(stop['consignmentstatus']);
if (state == ConsignmentState.unknown) {
// ── A row saying `Created` is the one row worth re-reading ──
//
// `Created` is the state a consignment holds for as long as it takes the
// pivot to route it, and `GET /miler/bookings` is a poll behind the rider by
// design. So a stop collected seconds ago can arrive on this tab carrying
// the state it had *before* `pickup-complete` finished with it, and every
// other state on the row is durable enough not to have that problem.
//
// Acting on it cost the rider the round: the guard below read it as hub
// custody and refused, and no amount of sliding cleared a row that only the
// next poll was going to correct. It is asked directly instead — one request,
// on the one state where the row is not evidence.
if (state == ConsignmentState.unknown ||
state == ConsignmentState.created) {
state = await ConsignmentGate.stateOf(consignmentId);
}
if (state.isDeliverable || state.isDelivered) return true;
if (!state.needsRelease && state != ConsignmentState.unknown) {
if (state.isDeliverable || state.isDelivered) {
// ── Released without a request, and the trace has to say so ──
//
// In compatibility mode the pivot has already released the consignment, so
// Start ride spends nothing here and the console was *already* Active
// before the rider slid. A silent `true` made that indistinguishable from
// a release this press actually made, which is the exact ambiguity the
// production trace has to resolve.
debugPrint(
'[TRACE][START-RIDE] consignment=$consignmentId '
'state="${state.name}" startDeliveryCalled=false '
'reason=already-released-by-pickup-complete (compatibility mode)',
);
return true;
}
// ── What may be *attempted*, as against what may be *claimed* ──
//
// This refused everything that was not `Collected_By_Miler` or unreadable,
// and `Created` fell into it wearing the hub's wording. Both halves were
// wrong for a parcel on the rider's own back.
//
// `Created` now goes to the server like `unknown` does, for the reason
// `unknown` does: the app is not the judge of a state it cannot fully
// account for, and one 400 carrying the backend's own sentence is worth more
// than a client-side refusal that invents one. A hyperlocal consignment the
// pivot has not finished releasing is let through; a hub-routed one is
// refused by the server, and the rider is told what the server said.
// A hold that cannot exist on this line is a bad read, not a fact about the
// stop. See [_handoffHoldIsPossible].
final heldElsewhere = state.awaitsHub && _handoffHoldIsPossible;
if (state.awaitsHub && !_handoffHoldIsPossible) {
debugPrint(
'[MILKRUN] $consignmentId read as ${state.name} on a line with no '
'handoff — treating as unreadable and asking the server',
);
}
if (!state.needsRelease &&
!state.awaitsHubInward &&
!(state.awaitsHub && !_handoffHoldIsPossible) &&
state != ConsignmentState.unknown) {
debugPrint('[MILKRUN] $consignmentId is ${state.name} — not releasable');
lastReleaseFailure = heldElsewhere
// The honest answer for a cross-city parcel: it is not his round, and
// saying so without naming a building he has never been to.
? "This one has already been handed on — it's not yours to deliver."
: 'This parcel cannot be started yet. Ask your office to check it.';
return false;
}
debugPrint(
'[TRACE][START-RIDE] consignment=$consignmentId state="${state.name}" '
'POST /miler/consignments/$consignmentId/start-delivery — calling',
);
final res = await MilerApi.startDelivery(consignmentId);
debugPrint(
'[TRACE][START-RIDE] consignment=$consignmentId startDeliveryCalled=true '
'-> ${res.status} ${res.code} ok=${res.ok} raw=${res.raw}',
);
if (res.ok) return true;
debugPrint(
@@ -242,7 +359,16 @@ Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
// Already on the road, or already handed over — either way this press is
// not what stands between the rider and the door.
final after = await ConsignmentGate.stateOf(consignmentId);
return after.isDeliverable || after.isDelivered;
if (after.isDeliverable || after.isDelivered) return true;
// `status: 0` is the request never leaving; anything else is the server
// answering, and its own words beat a guess about the network.
lastReleaseFailure = res.status == 0
? 'Could not reach your office. Check your connection and slide again.'
: (res.message.trim().isNotEmpty
? res.message.trim()
: 'Your office would not start this delivery.');
return false;
}
/// Brings this device into line with a delivery the server already has.
@@ -386,15 +512,42 @@ Future<Map<String, dynamic>?> _closeDelivery(
}
return {'outcome': 'completed', 'reconciled': true};
case DeliverGate.awaitingHub:
// The guard that must survive: a logistics consignment sitting at a
// hub genuinely is not this rider's to hand over.
debugPrint('[DELIVERY] $orderId is with the hub — refusing');
case DeliverGate.awaitingInward:
// Converted but never released and never inwarded. It is in his box,
// so the hub-hold sentence below would be false — but `deliver` will
// refuse it, so saying nothing and posting anyway would put a 400 on
// screen with no explanation attached.
debugPrint('[DELIVERY] $orderId is Created — not on the road yet');
if (context.mounted) {
AppFeedback.error(
context,
'This parcel is still with the hub. It can be delivered once the '
'hub releases it.',
"This parcel hasn't been released for delivery yet. Slide to "
'start the ride first, or ask your office to check it.',
);
}
return null;
case DeliverGate.awaitingHub:
// The guard that must survive: a logistics consignment inside the
// network genuinely is not this rider's to hand over.
//
// But only on a line that HAS a network. The same bad read that hit
// the Start-ride slider reaches this branch too, and refusing here
// is worse — the rider is at the door with the food in his hand.
// See [_handoffHoldIsPossible].
if (!_handoffHoldIsPossible) {
debugPrint(
'[DELIVERY] $orderId read as held on a line with no handoff — '
'ignoring the read and letting the server judge',
);
break;
}
debugPrint('[DELIVERY] $orderId is held elsewhere — refusing');
if (context.mounted) {
AppFeedback.error(
context,
"This one has already been handed on, so it can't be delivered "
'from here.',
);
}
return null;
@@ -441,9 +594,18 @@ Future<Map<String, dynamic>?> _closeDelivery(
}
}
if (outcome != DeliveryOutcome.cancelled && consignmentId.isEmpty) {
// Cancel is a *booking* route, so it is the one outcome that still works
// without a consignment.
if (outcome == DeliveryOutcome.delivered && consignmentId.isEmpty) {
// ── Only `deliver` needs a consignment ──
//
// This guard used to catch **skip** as well, and skip is the one thing a
// rider does when a delivery goes wrong — so the outcome he reaches for
// precisely when something is already wrong was the one refused with "this
// order's delivery reference is missing". Nothing he could do cleared it.
//
// Cancel was already exempt: it is a *booking* route. Skip now has one too
// (`POST /miler/bookings/:id/skip`, shipped 24 Aug), so both work without a
// consignment and only the hand-over — which genuinely keys on one — is
// held here.
//
// Reaching here means four sources came back empty, which is no longer a
// client-side gap: it means the pickup never converted this booking. The
@@ -492,7 +654,7 @@ Future<Map<String, dynamic>?> _closeDelivery(
context,
collected
? "This order's delivery reference is missing, so it cannot be "
'completed from the app. Ask your hub to check it — '
'completed from the app. Ask your office to check it — '
'collecting it again will not help.'
: 'This order was never picked up on the system — mark it picked '
'up from Home first, then deliver.',
@@ -516,6 +678,38 @@ Future<Map<String, dynamic>?> _closeDelivery(
// No fix, and the outcome still has to be recordable. See above.
}
// ── The photograph leaves the phone ──
//
// It never could: `deliver` takes `photourl`, which wants a URL, and nothing
// on the contract accepted an upload — so proof of delivery lived in the
// app's own directory and died with the next reinstall, on the one record
// somebody asks about weeks later.
//
// `POST /miler/uploads/sign` (24 Aug) closes it: sign, PUT the bytes, send
// the public URL back on the delivery.
//
// **A failed upload never blocks a hand-over.** The parcel is in the
// customer's hands whatever the network did, and `deliver` accepts an empty
// `photourl`. The local copy is kept either way, so a failure costs the hub
// its copy and costs the rider nothing.
String proofUrl = '';
if (outcome == DeliveryOutcome.delivered && proofPath.isNotEmpty) {
final uploaded = await MilerApi.uploadProof(
File(proofPath),
purpose: MilerApi.proofDelivery,
consignmentId: consignmentId.isEmpty ? null : consignmentId,
);
if (uploaded != null) {
proofUrl = uploaded;
} else {
ApiConfig.logGap(
'deliver',
'the proof photo for $orderId could not be uploaded; the delivery is '
'being recorded without one and the copy stays on the device.',
);
}
}
bool ok = false;
try {
switch (outcome) {
@@ -523,6 +717,7 @@ Future<Map<String, dynamic>?> _closeDelivery(
ok = await dc.updateDeliveredStatus(
pickupId: pickupIdInt,
consignmentId: consignmentId,
proofImage: proofUrl,
deliveredToName:
(stop['dropcustomer'] ??
stop['pickupcustomer'] ??
@@ -544,15 +739,55 @@ Future<Map<String, dynamic>?> _closeDelivery(
// fall back to the booking id and skip the wrong thing.
//
// The pickup-leg skip keeps that method; this is the delivery leg's.
final res = await MilerApi.skipConsignment(
consignmentId,
reason: notes.isEmpty ? 'Skipped by rider' : notes,
lat: riderFix?.latitude,
lon: riderFix?.longitude,
);
//
// ── Two routes, chosen by what the stop actually has ──
//
// With a consignment the skip belongs to it — that is the attempt
// counter the hub reads. Without one, the booking route is not a
// fallback but the *correct* call: a stop with no consignment has not
// been converted, so there is nothing on the delivery side to bump.
// Posting an empty id to `/consignments//skip` — which is what this
// did whenever the id was unreachable — 404s and tells the rider his
// own stop is broken.
final res = consignmentId.isEmpty
? await MilerApi.skipBooking(
stop['bookingid'] ?? stop['orderheaderid'] ?? orderId,
reason: notes.isEmpty ? 'Skipped by rider' : notes,
lat: riderFix?.latitude,
lon: riderFix?.longitude,
)
: await MilerApi.skipConsignment(
consignmentId,
reason: notes.isEmpty ? 'Skipped by rider' : notes,
lat: riderFix?.latitude,
lon: riderFix?.longitude,
);
ok = res.ok;
if (!ok) debugPrint('[DELIVERY][skip] ${res.status} ${res.message}');
// ── The attempt counter is the hub's, and it has a ceiling ──
//
// `skip` returns `attemptcount` and it is the source of truth: the
// consignment stays `Out_for_Delivery` and the parcel stays in the
// rider's hands, so nothing here may close the stop. At **3** the
// backend raises an Undeliverable exception for the hub, and there is
// no automated return or reassignment behind it — a human picks it up
// from there. Saying so is the difference between a rider trying a
// fourth time and a rider ringing the hub.
if (ok) {
final body = res.data;
final attempts = body is Map
? int.tryParse('${body['attemptcount'] ?? ''}') ?? 0
: 0;
if (attempts >= 3 && context.mounted) {
AppFeedback.info(
context,
'Third attempt on this stop — your office has been told and will '
'take it from here.',
);
}
}
case DeliveryOutcome.cancelled:
ok = await dc.updateCancelledStatus(
pickupId: pickupIdInt,
@@ -574,10 +809,26 @@ Future<Map<String, dynamic>?> _closeDelivery(
// show a finished screen while the office still had the order live. The
// rider keeps the stop and gets told why.
if (context.mounted) {
// ── Cancel is a booking route, and a collected parcel is past it ──
//
// `POST /miler/bookings/:id/cancel` is refused once the stop is picked
// up — by design: cancelling releases the booking for reassignment, and
// a parcel already in a rider's box cannot be handed to somebody else.
// The generic "could not record — try again" invited exactly the retry
// that can never work, so the one outcome with a *permanent* reason says
// it.
final collectedAlready =
outcome == DeliveryOutcome.cancelled &&
(stopStatusOf(stop).isPicked || stopStatusOf(stop).isDeliveryLeg);
AppFeedback.error(
context,
dc.lastBlockedReason ??
'Could not record ${outcome.pastTense.toLowerCase()} — try again',
collectedAlready
? 'You are already carrying this parcel, so it cannot be '
'cancelled from the app. Skip it to try again later, or ask '
'your office to cancel it.'
: dc.lastBlockedReason ??
'Could not record ${outcome.pastTense.toLowerCase()} — try again',
);
}
return null;

View File

@@ -11,6 +11,8 @@ import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
import 'package:miler/views/helpers/constants/narrative.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// PROOF OF DELIVERY
@@ -42,6 +44,37 @@ import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
/// without a photo, and null when he backed out entirely — three outcomes the
/// caller can tell apart.
/// ─────────────────────────────────────────────────────────────────────────
/// How a stop ended, as this page reports it.
///
/// The page is reached by sliding **deliver**, so *delivered* is the answer it
/// exists to collect. The other two are the answers a rider only has once he is
/// standing at the door — and the door is the last place he should have to back
/// out of a screen to record one.
enum ProofOutcome {
/// Handed over. Carries the photograph, when there is one.
delivered,
/// Not this time — a return visit. The parcel stays on board.
skipped,
/// It is not going to happen. The order ends here.
cancelled,
}
/// What [DeliveryProofPage] pops: the outcome, and the proof if the outcome
/// was a hand-over.
class ProofResult {
final ProofOutcome outcome;
/// Where the photograph was saved. `''` means the rider completed without
/// one — a camera that would not open, a customer who objected — which is a
/// real answer and not a failure. Always `''` on the other two outcomes:
/// there is nothing to photograph about a delivery that did not happen.
final String proofPath;
const ProofResult(this.outcome, [this.proofPath = '']);
}
class DeliveryProofPage extends StatefulWidget {
/// Names the stop on the page, so the rider can see he is closing the right
/// one before he commits.
@@ -56,6 +89,55 @@ class DeliveryProofPage extends StatefulWidget {
required this.orderId,
});
/// The two answers a rider has when the hand-over did not happen.
///
/// ── Public, because this page is no longer the only door ──
///
/// It was private and reachable only from the link under **Mark as
/// delivered**, which put Skip and Cancelled two taps below a control the
/// rider has to slide *first*. A rider at a locked gate had to commit to
/// delivering before he could say he had not.
///
/// The map sheet offers it beside its own slider now, so the answer is
/// available at the moment the rider learns it. One sheet, two entry points:
/// the wording, the order and the accents cannot drift between them, and a
/// third outcome added here appears in both places at once.
static Future<ProofOutcome?> askOutcome(BuildContext context) {
return showMilerSheet<ProofOutcome>(
context,
builder: (ctx) => MilerSheetScaffold(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const MilerSheetHeader(
title: 'What happened at this stop?',
subtitle: 'This is recorded with the office straight away.',
),
SizedBox(height: 8.h),
MilerSheetChoiceRow(
label: 'Skip — keep it on board and try again later',
icon: LucideIcons.clock,
accent: ColorConstants.warning,
selected: false,
showRule: false,
onTap: () => Navigator.pop(ctx, ProofOutcome.skipped),
),
MilerSheetChoiceRow(
label: 'Cancelled — this order will not be delivered',
icon: LucideIcons.circleX,
accent: ColorConstants.errorRed,
selected: false,
showRule: true,
onTap: () => Navigator.pop(ctx, ProofOutcome.cancelled),
),
SizedBox(height: 4.h),
],
),
),
);
}
@override
State<DeliveryProofPage> createState() => _DeliveryProofPageState();
}
@@ -104,7 +186,27 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
// A failed copy must not block the hand-over — the delivery is the thing
// that matters, and the caller is told there is no proof rather than
// being handed a path that resolves to nothing.
Navigator.of(context).pop(saved ?? '');
Navigator.of(context).pop(ProofResult(ProofOutcome.delivered, saved ?? ''));
}
/// ── The two ways a stop ends without a hand-over ──
///
/// They used to live under the slide on the map sheet, which is one screen
/// too early: a rider slides *deliver* because he intends to deliver, and he
/// finds out he cannot at the door — on this page. Backing out of here to
/// reach them meant the only way to report a failure was to abandon the
/// screen that records outcomes.
///
/// Behind a link and on a sheet, not as two more buttons: both are
/// irreversible and one of them ends the order, and this page is held
/// one-handed at a doorstep. The happy path keeps the only filled control.
Future<void> _cannotDeliver() async {
if (_busy) return;
final outcome = await DeliveryProofPage.askOutcome(context);
if (!mounted || outcome == null) return;
// No photograph travels with either of these: there is nothing to
// photograph about a delivery that did not happen.
Navigator.of(context).pop(ProofResult(outcome));
}
@override
@@ -120,12 +222,15 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
children: [
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 8.h),
// Bands, like the verification screen this page belongs
// beside: full-bleed white on the page's ground, the gap
// between them the only separator. See [MilerBand].
padding: EdgeInsets.only(bottom: 8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_who(),
SizedBox(height: 16.h),
_who(hasShot),
SizedBox(height: MilerBand.gap),
hasShot ? _preview() : _empty(),
],
),
@@ -138,10 +243,23 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
);
}
/// Which stop this closes. Named, because the one mistake this page can
/// enable is proving the wrong delivery.
Widget _who() => GlassCard(
padding: EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 14.h),
/// Which stop this closes, and the way to re-take the photo.
///
/// ── Retake came up here from the foot of the page ──
///
/// It sat at the bottom as a full-width outlined button beside **Mark as
/// delivered**, and that pairing was wrong twice over. It gave a *correction*
/// the same weight and the same row as the act that closes the order, on a
/// page whose one job is to close the order — so the rider's thumb had two
/// equal targets to choose between at the moment he least wants a choice.
/// And it put the control a screen away from the thing it controls: the photo
/// is at the top, and the button that replaces it was at the bottom.
///
/// It is a small round control on this band now, next to the person being
/// handed to. Reachable, obviously secondary, and beside the shot it retakes.
/// The bottom of the page is one button again.
Widget _who(bool hasShot) => MilerBand(
padding: EdgeInsets.fromLTRB(MilerBand.pad, 14.h, MilerBand.pad, 14.h),
child: Row(
children: [
Container(
@@ -149,7 +267,7 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
height: 40.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.acceptGreen.withValues(alpha: 0.12),
color: ColorConstants.tint(ColorConstants.acceptGreen, 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Icon(
@@ -189,108 +307,164 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
],
),
),
// Only once there is a shot to replace. Before that, taking the first
// one is the *primary* thing to do and it has the big target in the
// middle of the page — a second camera control up here would be the
// same action offered twice.
if (hasShot) ...[
SizedBox(width: 10.w),
Semantics(
button: true,
label: 'Retake the photo',
excludeSemantics: true,
child: GestureDetector(
onTap: _busy ? null : _capture,
child: Container(
width: 46.w,
height: 46.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.tint(ColorConstants.slateText, 0.07),
shape: BoxShape.circle,
border: Border.all(color: ColorConstants.borderSubtle),
),
child: Icon(
LucideIcons.rotateCw,
size: 20.sp,
color: ColorConstants.slateText,
),
),
),
),
],
],
),
);
/// The shot, at the size a judgement can be made from.
Widget _preview() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: AspectRatio(
aspectRatio: 3 / 4,
child: Image.file(
File(_shotPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => ColoredBox(
color: ColorConstants.cardSurface,
child: Center(
child: Icon(
LucideIcons.imageOff,
size: 32.sp,
color: ColorConstants.secondaryText,
Widget _preview() => MilerBand(
padding: EdgeInsets.fromLTRB(MilerBand.pad, 16.h, MilerBand.pad, 16.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: AspectRatio(
aspectRatio: 3 / 4,
child: Image.file(
File(_shotPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => ColoredBox(
color: ColorConstants.cardSurface,
child: Center(
child: Icon(
LucideIcons.imageOff,
size: 32.sp,
color: ColorConstants.secondaryText,
),
),
),
),
),
),
),
SizedBox(height: 12.h),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.circleCheck,
size: 16.sp,
color: ColorConstants.acceptGreen,
),
SizedBox(width: 6.w),
Flexible(
child: Text(
'Photo captured — it stays on this record',
maxLines: 1,
overflow: TextOverflow.ellipsis,
SizedBox(height: 12.h),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.circleCheck,
size: 16.sp,
color: ColorConstants.acceptGreen,
),
SizedBox(width: 6.w),
Flexible(
child: Text(
'Photo captured — it stays on this record',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
],
),
);
/// Before the first shot, or after the rider dismissed the camera.
///
/// ── The whole band opens the camera ──
///
/// It was inert artwork, and the way back to the camera was an **Open
/// camera** button at the foot of the page. That button is gone — the foot of
/// the page is one big *Mark as delivered* now — so this had to become the
/// control rather than a picture of one, or a rider who dismissed the camera
/// would have no way back to it at all.
///
/// It is the better target anyway: a whole band in the middle of the screen,
/// where the eye already is, instead of a third of a row at the bottom.
Widget _empty() => Semantics(
button: !_cameraFailed,
label: _cameraFailed
? 'Camera unavailable. You can still complete this delivery.'
: 'Take a photo of the handover',
excludeSemantics: true,
child: GestureDetector(
onTap: _busy || _cameraFailed ? null : _capture,
child: MilerBand(
padding: EdgeInsets.fromLTRB(MilerBand.pad, 32.h, MilerBand.pad, 32.h),
child: Column(
children: [
Container(
width: 84.w,
height: 84.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.tint(ColorConstants.primary, 0.08),
shape: BoxShape.circle,
),
child: Icon(
_cameraFailed ? LucideIcons.imageOff : LucideIcons.camera,
size: 38.sp,
color: ColorConstants.primary,
),
),
SizedBox(height: 16.h),
Text(
_cameraFailed
? 'Camera unavailable'
: 'Take a photo of the handover',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
fontSize: 17.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 6.h),
Text(
_cameraFailed
? 'You can still complete this delivery without a photo.'
: 'The parcel at the door, or with the person taking it.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.5.sp,
height: 1.45,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
],
),
),
],
);
/// Before the first shot, or after the rider dismissed the camera.
Widget _empty() => GlassCard(
padding: EdgeInsets.fromLTRB(20.w, 32.h, 20.w, 32.h),
child: Column(
children: [
Container(
width: 72.w,
height: 72.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.primary.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: Icon(
_cameraFailed ? LucideIcons.imageOff : LucideIcons.camera,
size: 32.sp,
color: ColorConstants.primary,
),
),
SizedBox(height: 16.h),
Text(
_cameraFailed ? 'Camera unavailable' : 'Take a photo of the handover',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 6.h),
Text(
_cameraFailed
? 'You can still complete this delivery without a photo.'
: 'The parcel at the door, or with the person taking it.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.5.sp,
height: 1.45,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
@@ -313,34 +487,56 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: MilerButton(
label: hasShot ? 'Retake' : 'Open camera',
icon: hasShot ? LucideIcons.rotateCw : LucideIcons.camera,
variant: MilerButtonVariant.outlined,
color: ColorConstants.slateText,
height: ButtonSizes.primary,
onPressed: _busy ? null : _capture,
),
// ── One button, and it is the size of the decision ──
//
// This row held two: an outlined **Retake** at one third and **Mark as
// delivered** at two thirds. Retake has gone up beside the photo it
// replaces (see [_who]), which leaves the act that closes the order
// alone at the foot of the page with the whole width to itself.
//
// 72pt rather than the app's ordinary 56. It is the last thing a rider
// does at a door, one-handed, often holding a parcel with the other —
// and it is the only control here, so nothing is crowded by giving it
// the room. The same height as the slide control on the map sheet, for
// the same reason: see [_SlideAction.height].
SizedBox(
height: 72.h,
child: MilerButton(
label: 'Mark as delivered',
icon: LucideIcons.circleCheck,
color: ColorConstants.acceptGreen,
height: 72.h,
loading: _busy,
onPressed: _busy ? null : _confirm,
),
),
// ── The way out, under the way forward ──
//
// Quiet, centred, and second: a rider who cannot hand over is the
// exception, and an exception must never be as easy to hit as the
// happy path. It is also the only route to Skip and Cancelled in the
// app, so it cannot be hidden either — a link under the button is
// where both of those are true at once.
SizedBox(height: 4.h),
TextButton(
onPressed: _busy ? null : _cannotDeliver,
style: TextButton.styleFrom(
minimumSize: Size(double.infinity, ButtonSizes.minTapTarget),
padding: EdgeInsets.zero,
),
child: Text(
"Couldn't hand it over?",
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
color: Narrative.inkMuted,
fontFamily: FontConstants.fontFamily,
),
SizedBox(width: 10.w),
Expanded(
flex: 2,
child: MilerButton(
label: 'Mark as delivered',
icon: LucideIcons.circleCheck,
color: ColorConstants.acceptGreen,
height: ButtonSizes.primary,
loading: _busy,
onPressed: _busy ? null : _confirm,
),
),
],
),
),
if (!hasShot) ...[
SizedBox(height: 8.h),
SizedBox(height: 4.h),
Text(
'No photo attached to this delivery',
textAlign: TextAlign.center,

View File

@@ -710,7 +710,7 @@ class _PickupsDoneState extends State<PickupsDone>
),
SizedBox(height: 8.h),
Text(
'Head back to the hub to drop the parcels and hand in any cash '
'Head back to base to drop the parcels and hand in any cash '
'you collected.',
textAlign: TextAlign.center,
style: TextStyle(
@@ -753,7 +753,7 @@ class _PickupsDoneState extends State<PickupsDone>
// A milk run ends at the last door, not at a depot — see
// [ServiceProfile.endsAtHub]. Capability, not screen position.
: (ServiceProfile.active.endsAtHub
? 'Return to hub'
? 'Return to base'
: 'Finish my round'),
icon: hasNext
? LucideIcons.arrowRight
@@ -980,7 +980,7 @@ class _PickupsDoneState extends State<PickupsDone>
label: hasNext
? 'Move to next stop'
: (ServiceProfile.active.endsAtHub
? 'Return to hub'
? 'Return to base'
: 'Finish my round'),
icon: hasNext
? LucideIcons.arrowRight

File diff suppressed because it is too large Load Diff

View File

@@ -14,10 +14,12 @@ library;
// what is removed here is this file's claim on them.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show kDebugMode, setEquals;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/narrative.dart';
import 'package:miler/views/helpers/constants/miler_surface.dart';
import 'package:miler/views/helpers/constants/miler_type.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
@@ -30,6 +32,7 @@ import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/helpers/poll_policy.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/views/helpers/widgets/slide_action.dart';
import 'package:miler/views/helpers/widgets/page_transitions.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
@@ -1436,16 +1439,47 @@ class _MyPickupsState extends State<MyPickups>
/// has nothing to offer. **No feature flag is mirrored here**, because a
/// mirrored flag is a second source of truth that can be wrong.
///
/// The local sets are the fallback for a row that carries no consignment
/// status yet — an older deployment, or a stop collected seconds ago that
/// the queue has not caught up with.
/// ── The rider's press decides this, not the server's rung ──
///
/// This read the consignment state first and only fell back to the local
/// sets when the row carried none. That is the right instinct — a local flag
/// must never outrank a server fact — and it produced the wrong answer for
/// the case that is live on production today.
///
/// Two backend behaviours ship at once. With `MILER_COLLECTED_STATE_ENABLED`
/// **on**, `pickup-complete` stops at `Collected_By_Miler` and this method's
/// old reading is exactly right. With it **off** — compatibility mode, which
/// is the default and what the app meets in production — `pickup-complete`
/// releases the consignment itself, so a stop arrives on this tab already
/// `Out_for_Delivery`, `needsRelease` answers false, and the round starts
/// itself. The rider never sees PICKED and **Start delivery** never appears:
/// he collects at the kitchen and the app has him on the road before he has
/// left the counter.
///
/// So the two questions are separated, because they were never the same one:
///
/// ```
/// may this be delivered? → the consignment. ConsignmentGate, deliver
/// has he set off? → the rider. this method
/// ```
///
/// Nothing here contradicts the server. A stop the app holds at PICKED is
/// still `Out_for_Delivery` on the wire, `deliver` is still gated on the
/// consignment's own state, and pressing **Start delivery** on a consignment
/// that is already released costs no request at all — `releaseForDelivery`
/// returns true on sight of a deliverable state. What changes is only which
/// of the two facts the rider's card is showing him, and the answer is the
/// one he can act on.
///
/// The consignment state stays the fallback for a stop this device has no
/// record of — collected on another handset, or after a reinstall.
bool _needsRelease(Map<String, dynamic> stop) {
final state = consignmentStateFromRaw(stop['consignmentstatus']);
if (state != ConsignmentState.unknown) {
return state.needsRelease;
}
final id = MilkRun.idOf(stop);
return _collectedIds.contains(id) && !_outForDeliveryIds.contains(id);
// He pressed it. Nothing outranks that.
if (_outForDeliveryIds.contains(id)) return false;
// He collected it here and has not pressed it.
if (_collectedIds.contains(id)) return true;
return consignmentStateFromRaw(stop['consignmentstatus']).needsRelease;
}
/// True while the round is being marked started, so the bar cannot be
@@ -1536,10 +1570,19 @@ class _MyPickupsState extends State<MyPickups>
final held = _unreleasedFailures;
if (n == 0) {
// One stop, one reason: say it. `releaseForDelivery` knows whether the
// parcel is at a hub, missing its reference, refused, or genuinely
// unreachable — see [lastReleaseFailure] — and "check your connection"
// is true for only the last of those.
//
// A whole round that failed keeps the general wording: with several
// stops there is no single reason to give, and the per-stop detail is in
// the log.
AppFeedback.errorGlobal(
held == 1
? 'Could not start that delivery. Check your connection and try '
'again.'
? (lastReleaseFailure ??
'Could not start that delivery. Check your connection and '
'try again.')
: 'Could not start the round. Check your connection and try again.',
);
return;
@@ -1751,23 +1794,46 @@ class _MyPickupsState extends State<MyPickups>
);
}
// ── Opening a stop starts its leg, if the bar has not already ──
// ── Opening a stop does NOT start its leg ──
//
// START DELIVERY is the normal way in and releases the whole load at once.
// This is the other way in: a rider who taps a card without pressing the
// bar is just as much on his way, and the stop has to be released before he
// gets there — `deliver` refuses any consignment that is not
// `Out_for_Delivery`, so opening one unreleased would walk him to a door
// and fail him at it.
// This used to call `startRound([item])` here — the same server release the
// Start-ride slider makes — on the reasoning that a rider who taps a card
// without pressing the bar is just as much on his way, and that `deliver`
// refuses anything that is not `Out_for_Delivery`, so an unreleased stop
// would walk him to a door and fail him at it.
//
// The same call the bar makes, for one order. [startRound] writes the local
// marker only after the server agrees, so a failure here leaves the row
// reading PICKED rather than lying about a round the hub has not started.
if (MilkRun.navigatesToCustomer(item, collectedIds: _collectedIds) &&
!_outForDeliveryIds.contains((item['orderid'] ?? '').toString())) {
await startRound([item]);
if (!mounted) return;
}
// ── Why that is the production lifecycle bug, one tap later ──
//
// [MilkRun.navigatesToCustomer] is true the moment a stop is in
// `_collectedIds`, which is the moment **Picked** succeeds. So the sequence
// that matters is:
//
// ```
// Home slide Picked → pickup-complete → Collected_By_Miler
// Work tap the card → start-delivery → Out_for_Delivery ← here
// Map slide Start ride (already released)
// ```
//
// Tapping a card is not a statement that the rider has set off. It is how
// he reads an address, checks a phone number, or looks at the route — and
// it fired the one call that is supposed to be the slider's alone, so the
// console went **Active** while the load was still on the counter and the
// rider had not slid anything.
//
// It is invisible today only because `MILER_COLLECTED_STATE_ENABLED` is
// off: the pivot has already released the consignment, so
// `releaseForDelivery` sees a deliverable state and returns true without
// spending a request. **The flag flip is what arms this.** Flipping it
// without this change moves the early-Active bug rather than fixing it.
//
// The door is still covered, and by the branch written for it: the
// `DeliverGate.needsRelease` case in `_closeDelivery` releases a stop the
// rider never started, at the moment he actually hands over, then re-reads
// the state before posting. That is the safety net this pre-release was
// duplicating — one tap too early and on the wrong side of the boundary.
//
// See `MilerLifecycle`, and the regression tests in
// `test/picked_does_not_release_test.dart`.
await openScreen(
context,
@@ -1962,10 +2028,25 @@ class _MyPickupsState extends State<MyPickups>
// `where` that used to sit here, re-admitting rows by raw status, is gone
// with it: it was the hole an accepted-but-uncollected booking came
// through, and a second expression of a fact that now has one.
// ── What the rider has already written off today ──
//
// Read from the two stores that record a mutation *after* it came back
// OK, so this is authoritative and not a display flag: `completed` holds
// delivered and cancelled, `skipped` holds a parked attempt. Both are
// day-stamped and scoped to this rider, tenant and line, and both survive
// a cold start — which is what stops `/miler/bookings` re-admitting a
// skipped stop on the next poll while the consignment is still
// `Out_for_Delivery`. See [WorkBoundary.isClosed].
final closedIds = <String>{
...await getCompletedOrderIds(),
...await getSkippedOrderIds(),
};
var dedupedList = WorkBoundary.deliveryQueue(
items.whereType<Map<String, dynamic>>(),
collectedIds: collectedIds,
acceptedIds: acceptedIds,
closedIds: closedIds,
);
// Drop stops the rider just finished. The backend may still return them
@@ -1996,17 +2077,17 @@ class _MyPickupsState extends State<MyPickups>
'${sortedList.map((o) => "${o['orderid']}:${o['orderstatus']}").toList()}',
);
// Re-apply locally-skipped orders so a skip survives polling even when the
// backend hasn't recorded it yet (e.g. mock/offline data). Without this,
// the 3s poll would overwrite the skip with the raw API status. Applied
// before the filter below, because the filter now acts on it.
if (_skippedOrdersCache.isNotEmpty) {
for (final o in sortedList) {
if (_skippedOrdersCache.containsKey(_getOrderKey(o))) {
o['orderstatus'] = 'skipped';
}
}
}
// ── The poll-time re-stamp is gone ──
//
// This used to walk the list writing `orderstatus = 'skipped'` onto every
// row in the in-memory skip cache, so that the 3s poll could not revert a
// skip the backend had not recorded. That was a display flag papering
// over dual ownership: the row was still in the queue, still owned by
// this tab, and merely wearing a different word.
//
// A skipped order no longer reaches this point — `closedIds` drops it at
// the boundary, above both screens — so there is nothing left to stamp
// and no second opinion to keep in sync. See [WorkBoundary.isClosed].
// Build the top list = the stops he is actually working.
// - ACTIVE stops go to the banner, not the list.
@@ -2895,14 +2976,20 @@ class _MyPickupsState extends State<MyPickups>
}
},
child: Scaffold(
// The same white as Home, Activity and Account. This was `surface`
// (#FCF9F8, a warm off-white); the difference is invisible under a full
// list and obvious the moment the list is short, so the tab appeared to
// change colour with the rider's workload.
// The brand shows through the sheet's two top corners — see
// [MilerSheet].
// ── The corner has to have something behind it ──
//
// The sheet's two top corners were clipping onto a ground painted in
// the same canvas the sheet itself uses, so the curve revealed the
// page's own colour and was invisible: this tab read as a hard maroon
// line above the queue while Home and Activity carried the shape. The
// brand goes behind the sheet, which is what the other tabs do and
// what makes the curve a curve. The page's own colour is the sheet's
// (`MilerSurface.canvas`, one line below), not this.
backgroundColor: ColorConstants.primary,
appBar: MilerAppBar(
// The bar's curve opens onto the sheet's canvas, not the app's
// paper. See [milerBrandSurface].
pageColor: MilerSurface.canvas,
// ── The tab and the page it opens must agree ──
//
// This was the literal 'My Bookings', while the nav tab underneath it
@@ -2936,322 +3023,342 @@ class _MyPickupsState extends State<MyPickups>
// [MilerSurface].
body: MilerSheet(
color: MilerSurface.canvas,
child: Stack(
children: [
// Three states, cross-faded: still asking, nothing to do, and the
// queue itself. The middle one used to cover the first, and the
// hand-off between any two of them used to be a single frame.
SmoothSwap(
child: _picked.isEmpty && !_firstLoadDone
? const SkeletonList(key: ValueKey('bookings-loading'))
: _picked.isEmpty && _fetchFailed
? ErrorRetry(
key: const ValueKey('bookings-error'),
onRetry: () => _fetchPicked(force: true),
)
: _picked.isEmpty
? SingleChildScrollView(
key: const ValueKey('bookings-empty'),
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.72,
// ── The artwork is the whole empty state ──
//
// This was a [MilerEmptyState] drawing a headline and a
// body line above the illustration — and the
// illustration has both baked into it, in the same
// words: "You're all caught up", then the line about
// new jobs appearing automatically. Rendered together
// the screen said each sentence twice, which reads as a
// rendering fault rather than as one message. The panel
// is the picture now, at a size worth looking at, and
// it keeps its own copy.
//
// [_fetchDiagLine] stays: it is `kDebugMode`-gated and
// folds away entirely in release, so nothing ships
// underneath the art.
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.w,
),
child: Image.asset(
'assets/images/caught_up.png',
width: 330.w,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Icon(
LucideIcons.truck,
size: 64.sp,
color: ColorConstants.borderStrong,
// ── The sheet has to fill the body, not the queue ──
//
// A `Stack` hands its children loose constraints and then shrinks to
// the tallest of them, so on a short queue — or the empty state —
// the sheet ended where the content did and the rest of the body was
// whatever the Scaffold was painted with. That was invisible while
// the ground was the same canvas as the sheet; the moment the brand
// went behind it (for the corners) it became a red band across the
// bottom of the tab.
child: SizedBox.expand(
child: Stack(
children: [
// Three states, cross-faded: still asking, nothing to do, and the
// queue itself. The middle one used to cover the first, and the
// hand-off between any two of them used to be a single frame.
SmoothSwap(
child: _picked.isEmpty && !_firstLoadDone
? const SkeletonList(key: ValueKey('bookings-loading'))
: _picked.isEmpty && _fetchFailed
? ErrorRetry(
key: const ValueKey('bookings-error'),
onRetry: () => _fetchPicked(force: true),
)
: _picked.isEmpty
? SingleChildScrollView(
key: const ValueKey('bookings-empty'),
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.72,
// ── The artwork is the whole empty state ──
//
// This was a [MilerEmptyState] drawing a headline and a
// body line above the illustration — and the
// illustration has both baked into it, in the same
// words: "You're all caught up", then the line about
// new jobs appearing automatically. Rendered together
// the screen said each sentence twice, which reads as a
// rendering fault rather than as one message. The panel
// is the picture now, at a size worth looking at, and
// it keeps its own copy.
//
// [_fetchDiagLine] stays: it is `kDebugMode`-gated and
// folds away entirely in release, so nothing ships
// underneath the art.
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.w,
),
child: Image.asset(
'assets/images/caught_up.png',
width: 330.w,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Icon(
LucideIcons.truck,
size: 64.sp,
color: ColorConstants.borderStrong,
),
),
),
),
_fetchDiagLine(),
],
_fetchDiagLine(),
],
),
),
),
),
)
: Column(
key: const ValueKey('bookings-list'),
children: [
// No trip tabs here. Everything on this screen is already
// accepted, so the only live question is which stop is
// next — and that has one answer across the whole day.
// See [_visibleStops].
_buildTripRail(),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchPicked,
color: ColorConstants.primary,
// ── An empty ROW list, not an empty stop list ──
//
// This asked `_visibleStops.isEmpty`, but the rows
// are built from a filtered subset of it. When the
// stops were all finished-but-not-yet-cleared, the
// page took the populated branch and rendered a
// `ListView` with zero children: a blank screen
// under the rail, with no explanation and nothing
// to pull on. Ask the list that is actually drawn.
child: _queueRows().isEmpty
? _buildTripTabEmptyState()
: Builder(
builder: (context) {
final rows = _queueRows();
return ListView.builder(
padding: EdgeInsets.only(
top: 2.h,
// Room for the live strip only when
// one is actually drawn — see
// [_liveOnThisScreen].
// ── Room for whichever bar is
// drawn ──
//
// A flat 80 was the live strip's
// height, and the round bar is
// taller: a caption, a primary
// button and the nav-bar inset
// under it. The last card sat
// behind it with its controls
// unreachable. Measured from the
// pieces rather than guessed, so it
// cannot drift when either grows.
// ── One measured floor ──
//
// The unreleased branch was built
// from its bar's own pieces; the
// live branch was a flat `80.h`
// that forgot the device inset
// entirely, so on a gesture-nav
// phone the last row of the run sat
// behind the banner *and* behind
// the navigation. Both surfaces now
// state their own height and the
// floor reserves whichever one is
// actually showing, plus the inset.
bottom: _bottomReservation(context),
),
itemCount: rows.length,
itemBuilder: (context, index) {
final row = rows[index];
if (row.isHeading) {
return _QueueHeading(
label: row.heading!,
count: row.count,
// On the first heading only:
// it describes the whole
// queue, not the section.
order: index == 0
? _orderSource
: null,
);
}
final item = row.stop!;
final orderId =
(item['orderid'] ?? '')
.toString();
// Live metres + speed drive the
// distance / ETA on every density;
// RouteMetricsHelper picks the unit
// and falls back to an urban average
// when the GPS reading is unusable.
final distanceMeters =
RouteMetricsHelper.metersToStop(
item,
riderLat: _currentLocation
?.latitude,
riderLng: _currentLocation
?.longitude,
// The leg decides the end:
// a delivery row measures
// to the DOOR. It measured
// to the kitchen, so the
// figure grew as the rider
// approached the customer.
toDrop: MilkRun.workingKind(
item,
collectedIds: _collectedIds,
).isDelivery,
)
: Column(
key: const ValueKey('bookings-list'),
children: [
// No trip tabs here. Everything on this screen is already
// accepted, so the only live question is which stop is
// next — and that has one answer across the whole day.
// See [_visibleStops].
_buildTripRail(),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchPicked,
color: ColorConstants.primary,
// ── An empty ROW list, not an empty stop list ──
//
// This asked `_visibleStops.isEmpty`, but the rows
// are built from a filtered subset of it. When the
// stops were all finished-but-not-yet-cleared, the
// page took the populated branch and rendered a
// `ListView` with zero children: a blank screen
// under the rail, with no explanation and nothing
// to pull on. Ask the list that is actually drawn.
child: _queueRows().isEmpty
? _buildTripTabEmptyState()
: Builder(
builder: (context) {
final rows = _queueRows();
return ListView.builder(
padding: EdgeInsets.only(
top: 2.h,
// Room for the live strip only when
// one is actually drawn — see
// [_liveOnThisScreen].
// ── Room for whichever bar is
// drawn ──
//
// A flat 80 was the live strip's
// height, and the round bar is
// taller: a caption, a primary
// button and the nav-bar inset
// under it. The last card sat
// behind it with its controls
// unreachable. Measured from the
// pieces rather than guessed, so it
// cannot drift when either grows.
// ── One measured floor ──
//
// The unreleased branch was built
// from its bar's own pieces; the
// live branch was a flat `80.h`
// that forgot the device inset
// entirely, so on a gesture-nav
// phone the last row of the run sat
// behind the banner *and* behind
// the navigation. Both surfaces now
// state their own height and the
// floor reserves whichever one is
// actually showing, plus the inset.
bottom: _bottomReservation(
context,
),
),
itemCount: rows.length,
itemBuilder: (context, index) {
final row = rows[index];
if (row.isHeading) {
return _QueueHeading(
label: row.heading!,
count: row.count,
// On the first heading only:
// it describes the whole
// queue, not the section.
order: index == 0
? _orderSource
: null,
);
}
void open() => StopDetailSheet.show(
context,
stop: item,
stopNumber: row.displayStep,
// The pairing recorded at the
// counter — same store as the
// card, so the two agree.
bag: row.bag,
// The carried set — what turns
// a raw pickup row into the
// delivery leg the sheet must
// describe.
collectedIds: _collectedIds,
riderLat:
_currentLocation?.latitude,
riderLng:
_currentLocation?.longitude,
);
final item = row.stop!;
final orderId =
(item['orderid'] ?? '')
.toString();
// Live metres + speed drive the
// distance / ETA on every density;
// RouteMetricsHelper picks the unit
// and falls back to an urban average
// when the GPS reading is unusable.
final distanceMeters =
RouteMetricsHelper.metersToStop(
item,
riderLat: _currentLocation
?.latitude,
riderLng: _currentLocation
?.longitude,
// The leg decides the end:
// a delivery row measures
// to the DOOR. It measured
// to the kitchen, so the
// figure grew as the rider
// approached the customer.
toDrop: MilkRun.workingKind(
item,
collectedIds:
_collectedIds,
).isDelivery,
);
// ── One card shape per density ──
//
// Not one card dimmed four ways. See
// [QueueDensity] for why the live stop
// is physically bigger rather than
// merely brighter.
final Widget child = switch (row
.density) {
QueueDensity.now => PickupCard(
key: ValueKey(
'pickup_$orderId',
),
item: item,
displayStep: row.displayStep,
distanceMeters: distanceMeters,
riderSpeedMps:
_currentLocation?.speed,
void
open() => StopDetailSheet.show(
context,
stop: item,
stopNumber: row.displayStep,
// The pairing recorded at the
// counter — same store as the
// card, so the two agree.
bag: row.bag,
// The carried set — what turns
// a raw pickup row into the
// delivery leg the sheet must
// describe.
collectedIds: _collectedIds,
outForDeliveryIds:
_outForDeliveryIds,
),
QueueDensity.next => _NextCard(
item: item,
bag: row.bag,
distanceMeters: distanceMeters,
riderSpeedMps:
_currentLocation?.speed,
onOpen: open,
),
QueueDensity.later => _LaterRow(
item: item,
bag: row.bag,
distanceMeters: distanceMeters,
onOpen: open,
),
QueueDensity.skipped =>
_SkippedRow(
riderLat:
_currentLocation?.latitude,
riderLng:
_currentLocation?.longitude,
);
// ── One card shape per density ──
//
// Not one card dimmed four ways. See
// [QueueDensity] for why the live stop
// is physically bigger rather than
// merely brighter.
final Widget child = switch (row
.density) {
QueueDensity.now => PickupCard(
key: ValueKey(
'pickup_$orderId',
),
item: item,
displayStep: row.displayStep,
distanceMeters:
distanceMeters,
riderSpeedMps:
_currentLocation?.speed,
bag: row.bag,
collectedIds: _collectedIds,
outForDeliveryIds:
_outForDeliveryIds,
),
QueueDensity.next => _NextCard(
item: item,
bag: row.bag,
reason: row.reason,
distanceMeters:
distanceMeters,
riderSpeedMps:
_currentLocation?.speed,
onOpen: open,
onResume: () =>
_resumeSkipped(item),
),
};
QueueDensity.later => _LaterRow(
item: item,
bag: row.bag,
distanceMeters:
distanceMeters,
onOpen: open,
),
QueueDensity.skipped =>
_SkippedRow(
item: item,
bag: row.bag,
reason: row.reason,
onOpen: open,
onResume: () =>
_resumeSkipped(item),
),
};
return Reveal(
key: ValueKey('reveal_$orderId'),
delay: staggerDelay(index),
child: child,
);
},
);
},
),
return Reveal(
key: ValueKey(
'reveal_$orderId',
),
delay: staggerDelay(index),
child: child,
);
},
);
},
),
),
),
),
],
),
),
// The live strip, shared with Home. It places itself clear of the
// floating nav bar, so it is pinned flush here and left to it.
//
// Only for a stop this screen can actually work. On a kitchen line
// a collection is Home's, in bulk, so a strip here offering to
// "continue" one was an invitation into a flow the rider is not
// supposed to be in — and the way most riders found it. See
// [_liveOnThisScreen].
// ── The hinge of the day, at the foot of the tab that holds the
// load ──
//
// It shares this slot with the live strip and yields to it: once a
// stop is running, "continue that stop" is the only thing the rider
// wants from the bottom of this screen, and a second bar under his
// thumb offering to start something else is how the wrong one gets
// pressed. See [startRound].
// ── One contextual slot, and the workflow advances through it ──
//
// These were two `if`s over the same corner. Logically exclusive
// already — a live stop suppresses the start bar, because a
// second bar under the rider's thumb offering to start something
// else is how the wrong one gets pressed — but they *swapped* by
// one disappearing and the other appearing in the same frame,
// which reads as the screen glitching rather than as **Start
// delivery → Continue this stop**.
//
// One switcher, one child at a time, on the app's own state
// duration. Keyed by which surface it is rather than by its
// contents, so a poll that returns the same live stop does not
// replay the transition.
Positioned(
left: 0,
right: 0,
bottom: 0,
child: AnimatedSwitcher(
duration: DesignConstants.motionState,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.12),
end: Offset.zero,
).animate(anim),
child: child,
),
),
child: _liveOnThisScreen.isNotEmpty
? ActivePickupBanner(
key: const ValueKey('live'),
activePickups: _liveOnThisScreen,
onTap: (pickup) async {
await startPickup(pickup);
// Refresh after returning from map screen
if (mounted) {
await Future.delayed(
const Duration(milliseconds: 500),
);
_fetchPicked();
}
},
)
: _unreleased.isNotEmpty
? StartRoundBar(
key: const ValueKey('start-round'),
count: _unreleased.length,
busy: _starting,
onPressed: _startWholeRound,
)
: const SizedBox.shrink(key: ValueKey('none')),
],
),
),
),
],
// The live strip, shared with Home. It places itself clear of the
// floating nav bar, so it is pinned flush here and left to it.
//
// Only for a stop this screen can actually work. On a kitchen line
// a collection is Home's, in bulk, so a strip here offering to
// "continue" one was an invitation into a flow the rider is not
// supposed to be in — and the way most riders found it. See
// [_liveOnThisScreen].
// ── The hinge of the day, at the foot of the tab that holds the
// load ──
//
// It shares this slot with the live strip and yields to it: once a
// stop is running, "continue that stop" is the only thing the rider
// wants from the bottom of this screen, and a second bar under his
// thumb offering to start something else is how the wrong one gets
// pressed. See [startRound].
// ── One contextual slot, and the workflow advances through it ──
//
// These were two `if`s over the same corner. Logically exclusive
// already — a live stop suppresses the start bar, because a
// second bar under the rider's thumb offering to start something
// else is how the wrong one gets pressed — but they *swapped* by
// one disappearing and the other appearing in the same frame,
// which reads as the screen glitching rather than as **Start
// delivery → Continue this stop**.
//
// One switcher, one child at a time, on the app's own state
// duration. Keyed by which surface it is rather than by its
// contents, so a poll that returns the same live stop does not
// replay the transition.
Positioned(
left: 0,
right: 0,
bottom: 0,
child: AnimatedSwitcher(
duration: DesignConstants.motionState,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.12),
end: Offset.zero,
).animate(anim),
child: child,
),
),
child: _liveOnThisScreen.isNotEmpty
? ActivePickupBanner(
key: const ValueKey('live'),
activePickups: _liveOnThisScreen,
onTap: (pickup) async {
await startPickup(pickup);
// Refresh after returning from map screen
if (mounted) {
await Future.delayed(
const Duration(milliseconds: 500),
);
_fetchPicked();
}
},
)
: _unreleased.isNotEmpty
? StartRoundBar(
key: const ValueKey('start-round'),
count: _unreleased.length,
busy: _starting,
onPressed: _startWholeRound,
)
: const SizedBox.shrink(key: ValueKey('none')),
),
),
],
),
),
),
),

View File

@@ -683,7 +683,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
if (payRes?['status'] != true && mounted) {
AppFeedback.error(
context,
"Payment recorded on your phone but not with the hub — tell them "
"Payment recorded on your phone but not with your office — tell them "
"you collected ₹${_paidAmount.toStringAsFixed(0)}",
);
}
@@ -1285,19 +1285,27 @@ class _StopBrief extends StatelessWidget {
children: [
Row(
children: [
// ── The avatar is a disc, and it is neutral ──
//
// A tinted rounded square in the leg's accent put a third coloured
// object on a sheet that already has a state pill and a coloured
// rail, and the colour said nothing the two of them had not
// already said. Initials are an identity, not a state: they take
// the app's quiet inset tone, and the round shape is what tells
// the eye it stands for a person.
Container(
width: 40.w,
height: 40.w,
width: 42.w,
height: 42.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: kind.accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
decoration: const BoxDecoration(
color: ColorConstants.neutralLight,
shape: BoxShape.circle,
),
child: Text(
initials,
style: TextStyle(
color: kind.accent,
fontSize: 13.5.sp,
color: ColorConstants.secondaryText,
fontSize: 14.sp,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
),
@@ -1355,8 +1363,12 @@ class _StopBrief extends StatelessWidget {
// Aligned to the avatar's column so the two rows read as one block
// rather than as two things that happen to be near each other.
SizedBox(
width: 40.w,
child: Icon(LucideIcons.mapPin, size: 20.sp, color: kind.accent),
width: 42.w,
child: Icon(
LucideIcons.mapPin,
size: 20.sp,
color: ColorConstants.primary,
),
),
SizedBox(width: 12.w),
Expanded(
@@ -1364,12 +1376,20 @@ class _StopBrief extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
// ── The destination label is brand ──
//
// It took the leg's accent — blue on a delivery — which is
// a *classification* colour, and this line is not
// classifying anything: the rider is already on the screen
// for this stop. What it is doing is pointing at where he
// has to go, and pointing is what the brand does on this
// sheet (the rail, the pin beside this label).
kind.locationLabel,
style: TextStyle(
fontSize: 9.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: kind.accent,
color: ColorConstants.primary,
fontFamily: FontConstants.fontFamily,
),
),

View File

@@ -209,7 +209,7 @@ class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
_quoteError = quote.found
? null
: 'No price is configured for this weight and route. '
'Call the hub before collecting.';
'Call your office before collecting.';
});
}
@@ -297,14 +297,22 @@ class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 24.h),
// ── The bands carry the gutter, the page does not ──
//
// Every child below is full-bleed and pads its own content by
// [_bandPad]. The page used to hold a 16pt margin and give each
// card a border and a radius on top of it, which spent 32 of a
// 390pt phone's width on two strips of grey either side of a
// form whose fields are addresses. The ground showing between
// two bands separates them just as well and costs nothing.
padding: EdgeInsets.only(bottom: 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_journeyCard(),
SizedBox(height: 14.h),
SizedBox(height: MilerBand.gap),
_parcelCard(),
SizedBox(height: 14.h),
SizedBox(height: MilerBand.gap),
_quotePanel(),
],
),
@@ -360,13 +368,7 @@ class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
/// and it is the shape because it is read in one glance.
/// ─────────────────────────────────────────────────────────────────────
Widget _journeyCard() {
return Container(
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 16.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.borderSubtle, width: 1.2),
),
return MilerBand(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -508,14 +510,14 @@ class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
/// One surface rather than a heading and three loose blocks, for the same
/// reason as the journey above: these three answers are one description of
/// one parcel, and the price is computed from all three together.
/// The inset a full-bleed band keeps around its content. The app's own, so
/// this page and the verification screen that hands off to it line up at the
/// same left margin. See [MilerBand].
double get _bandPad => MilerBand.pad;
Widget _parcelCard() {
return Container(
padding: EdgeInsets.fromLTRB(14.w, 15.h, 14.w, 16.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.borderSubtle, width: 1.2),
),
return MilerBand(
padding: EdgeInsets.fromLTRB(_bandPad, 15.h, _bandPad, 16.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -588,21 +590,14 @@ class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
final quote = _quote;
final priced = quote != null && !_quoting && _quoteError == null;
return Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 16.h),
decoration: BoxDecoration(
color: priced
? ColorConstants.acceptGreen.withValues(alpha: 0.06)
: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(
color: priced
? ColorConstants.acceptGreen.withValues(alpha: 0.28)
: ColorConstants.borderSubtle,
width: 1.2,
),
),
// A priced band goes pale green and takes the rail; an unpriced one stays
// white. Both fills are opaque colours rather than the accent laid over the
// page — see [ColorConstants.tint].
return MilerBand(
color: priced
? ColorConstants.tint(ColorConstants.acceptGreen, 0.06)
: ColorConstants.pureSurface,
rail: priced ? ColorConstants.acceptGreen : null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@@ -207,25 +207,29 @@ class _ShipmentReviewPageState extends State<ShipmentReviewPage>
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 24.h),
// Full-bleed bands, like the verification screen this page is the
// end of. The section headers moved inside the band they head — a
// heading on the page and a card under it puts two left margins on
// one screen, which is the thing bands exist to stop. See
// [MilerBand].
padding: EdgeInsets.only(bottom: 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_routeCard(),
SizedBox(height: 14.h),
const SectionHeader(title: 'Shipment'),
SizedBox(height: 8.h),
SizedBox(height: MilerBand.gap),
_shipmentCard(),
SizedBox(height: 14.h),
const SectionHeader(title: 'Payment'),
SizedBox(height: 8.h),
SizedBox(height: MilerBand.gap),
_paymentCard(),
if (_error != null) ...[
SizedBox(height: 14.h),
InfoBanner(
icon: LucideIcons.circleAlert,
text: _error!,
color: ColorConstants.errorRed,
SizedBox(height: MilerBand.gap),
Padding(
padding: EdgeInsets.symmetric(horizontal: MilerBand.pad),
child: InfoBanner(
icon: LucideIcons.circleAlert,
text: _error!,
color: ColorConstants.errorRed,
),
),
],
],
@@ -239,7 +243,7 @@ class _ShipmentReviewPageState extends State<ShipmentReviewPage>
/// From → to, drawn as a journey rather than two labelled fields.
Widget _routeCard() {
return AppCard(
return MilerBand(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -345,9 +349,12 @@ class _ShipmentReviewPageState extends State<ShipmentReviewPage>
final weight = widget.shipment['weight'];
final km = widget.shipment['approxkm'];
return AppCard(
return MilerBand(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(title: 'Shipment'),
SizedBox(height: 4.h),
_row('Weight', weight == null ? '—' : '$weight kg'),
_divider(),
_row('Category', _s(widget.shipment['category'])),
@@ -374,9 +381,12 @@ class _ShipmentReviewPageState extends State<ShipmentReviewPage>
}
Widget _paymentCard() {
return AppCard(
return MilerBand(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(title: 'Payment'),
SizedBox(height: 10.h),
Row(
children: [
for (final m in ShipmentPayMethod.values) ...[
@@ -425,7 +435,7 @@ class _ShipmentReviewPageState extends State<ShipmentReviewPage>
padding: EdgeInsets.symmetric(vertical: 12.h),
decoration: BoxDecoration(
color: selected
? ColorConstants.primary.withValues(alpha: 0.08)
? ColorConstants.tint(ColorConstants.primary, 0.08)
: ColorConstants.daylightSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
border: Border.all(

View File

@@ -299,7 +299,7 @@ Future<void> _showMyOptionsSheet(
// rather than a variant.
const MilerSheetHeader(
title: 'Skip this pickup',
subtitle: 'Tell us why, so the hub can reassign it.',
subtitle: 'Tell us why, so your office can reassign it.',
),
SizedBox(height: 16.h),
// ── Which pickup ──

View File

@@ -427,18 +427,25 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _back();
},
// ── White page, tinted panels ──
// ── Grey page, white bands ──
//
// 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.
// It was the other way round — a white page carrying tinted panels — and
// that inversion was itself a fix for panels that would not separate.
// Both readings work; this one is taken because of what it does to the
// *width*. A tinted panel has to be inset from the page or its fill runs
// into the screen edge, so every section sat inside a 20pt gutter and the
// page spent 40 of its 390 points on two strips of white that said
// nothing. A white band on a grey ground has no such problem: it can run
// the full width, because the ground showing above and below it is what
// separates one section from the next.
//
// So the sections are full-bleed now and the gutter lives *inside* them,
// where it is padding around content rather than margin around a box.
// Same air next to the words, ~40pt more of it for the words themselves —
// which on this screen is the difference between an address on two lines
// and an address on three.
child: Scaffold(
backgroundColor: ColorConstants.pureSurface,
backgroundColor: ColorConstants.daylightSurface,
// ── The bar breathes, and its title is a title ──
//
// 17sp jammed against the status bar is a system default, not a
@@ -462,15 +469,17 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 24.h),
// No horizontal padding: every child of this column is a
// full-bleed band that carries its own. See the note on the
// scaffold's ground.
padding: EdgeInsets.only(bottom: 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_stopSummary(),
// No rule under the summary any more: the steps below
// are panels with their own edges, and a divider between a
// card and a card is a line drawn between two boundaries
// that already exist.
// are bands with the page's ground between them, and a
// divider between two boundaries is a third boundary.
if (_hasReview && _step >= _legs.length) ...[
SizedBox(height: 6.h),
_reviewStep(),
@@ -639,136 +648,155 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
// first thing on the page, so every leg opened by drawing the eye to the one
// block that needs nothing done to it. Unboxed it reads as a heading, which
// is what it is.
/// The stop, as the band the form is being filled in *about*.
///
/// Full-bleed white, like every section under it, so the page reads as one
/// stack of bands rather than a heading floating over a stack of boxes.
Widget _stopSummary() {
return 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,
return MilerBand(
padding: EdgeInsets.fromLTRB(_bandPad, 16.h, _bandPad, 18.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Both halves give ground, neither is cut off ──
//
// A fixed badge, a `Spacer` and a fixed amount: the row was wider
// than a 360pt phone from 1.3x system text upward, and the rider who
// has turned his font up is the one who most needs to read "₹2450 to
// collect". Flexible on both sides costs nothing at normal size —
// each still takes its natural width — and lets the longer one
// ellipsise instead of striping the card black and yellow.
Row(
children: [
// The stop type as a dotted label rather than a filled badge. It
// is an attribute of the name below it, and a saturated chip made
// it the loudest thing on a screen whose subject is the form.
Container(
width: 6.w,
height: 6.w,
margin: EdgeInsets.only(right: 8.w),
decoration: BoxDecoration(
color: _kind.accent,
fontFamily: FontConstants.fontFamily,
shape: BoxShape.circle,
),
),
),
const Spacer(),
if (_cash > 0)
Flexible(
child: Text(
'₹${_cash.toStringAsFixed(0)} to collect',
_kind.titleLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.acceptGreen,
color: _kind.accent,
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,
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: 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,
),
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,
),
Expanded(
child: Text(
_address,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: MilerType.micro.copyWith(
fontSize: 13.sp,
height: 1.4,
color: ColorConstants.onSurfaceVariant,
),
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,
),
),
),
],
),
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,
Expanded(
child: Text(
_address,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: MilerType.micro.copyWith(
fontSize: 13.sp,
height: 1.4,
color: ColorConstants.onSurfaceVariant,
),
),
),
if (_expectedPickup > 0)
_loadPill(
LucideIcons.package,
'Collect $_expectedPickup',
ColorConstants.pickupAccent,
),
],
),
],
],
),
SizedBox(height: 12.h),
// Wrap rather than Row: on a combined stop the two pills together are
// wider than the page once the system font is up, and the second one
// belongs on its own line rather than off the edge of the screen.
Wrap(
spacing: 7.w,
runSpacing: 7.h,
children: [
if (_expectedDelivery > 0)
_loadPill(
LucideIcons.truck,
'Hand over $_expectedDelivery',
ColorConstants.deliveryAccent,
),
if (_expectedPickup > 0)
_loadPill(
LucideIcons.package,
'Collect $_expectedPickup',
ColorConstants.pickupAccent,
),
],
),
],
),
);
}
/// A finished band's ground: the accept green at 5.5%, resolved onto white
/// so it is an ordinary opaque colour rather than a wash that changes with
/// what is behind it.
static final Color _doneFill = ColorConstants.tint(
ColorConstants.acceptGreen,
0.055,
);
/// The inset a band keeps around its content — the app's own, so this page
/// and the shipment desk it hands off to line up at the same left margin.
double get _bandPad => MilerBand.pad;
Widget _loadPill(IconData icon, String label, Color accent) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 6.h),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.10),
color: ColorConstants.tint(accent, 0.10),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Row(
@@ -897,7 +925,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h),
decoration: BoxDecoration(
color: ticked
? ColorConstants.acceptGreen.withValues(alpha: 0.10)
? ColorConstants.tint(ColorConstants.acceptGreen, 0.10)
: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
// The tick is the whole point of this control, so the untouched state
@@ -1167,10 +1195,12 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
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),
// The heading is the screen talking rather than a section of it, but it
// is still a band: with full-bleed sections under it, a heading inset
// by its own margin is the one thing on the page whose left edge does
// not line up with everything else.
MilerBand(
padding: EdgeInsets.fromLTRB(_bandPad, 16.h, _bandPad, 18.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1219,7 +1249,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
],
),
),
SizedBox(height: 14.h),
SizedBox(height: MilerBand.gap),
if (_kind.hasDelivery)
_reviewLeg(
@@ -1251,7 +1281,8 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
],
),
if (_kind.hasDelivery && _kind.hasPickup) SizedBox(height: 12.h),
if (_kind.hasDelivery && _kind.hasPickup)
SizedBox(height: MilerBand.gap),
if (_kind.hasPickup)
_reviewLeg(
@@ -1296,7 +1327,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
),
if (_cash > 0) ...[
SizedBox(height: 12.h),
SizedBox(height: MilerBand.gap),
_notice(
'Collect ₹${_cash.toStringAsFixed(0)} from the customer on the '
'next screen',
@@ -1326,13 +1357,10 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
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),
),
// White, and full-bleed like every other band. It was a tinted rounded card,
// which on a grey page is a box on a box: the ground between two bands
// already says these are two legs of the stop.
return MilerBand(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1375,8 +1403,8 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
return Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
color: ColorConstants.tint(color, 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -1425,7 +1453,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 14.h),
decoration: BoxDecoration(
color: ColorConstants.warning.withValues(alpha: 0.10),
color: ColorConstants.tint(ColorConstants.warning, 0.10),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Row(
@@ -1904,26 +1932,19 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
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,
),
),
padding: EdgeInsets.only(top: MilerBand.gap),
// ── State in the fill, and the fill is a colour rather than a wash ──
//
// Done recedes and outstanding keeps the page; that is the right property
// to carry it, because a fill is the one thing readable at arm's length
// in sun. Both states are opaque colours rather than the accent laid over
// whatever is behind (see [ColorConstants.tint]), and the finished band
// adds the rail, which is what replaced the tinted outline these sections
// used to close with. See [MilerBand].
child: MilerBand(
color: done ? _doneFill : ColorConstants.pureSurface,
rail: done ? ColorConstants.acceptGreen : null,
padding: EdgeInsets.fromLTRB(_bandPad, 15.h, _bandPad, 17.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1935,7 +1956,11 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
height: 30.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.11),
color: ColorConstants.tint(
accent,
0.11,
on: done ? _doneFill : ColorConstants.pureSurface,
),
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
@@ -2020,7 +2045,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
return Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
decoration: BoxDecoration(
color: ColorConstants.warning.withValues(alpha: 0.10),
color: ColorConstants.tint(ColorConstants.warning, 0.10),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
child: Text(
@@ -2274,7 +2299,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
padding: EdgeInsets.symmetric(vertical: 30.h),
width: double.infinity,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.06),
color: ColorConstants.tint(accent, 0.06),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: accent.withValues(alpha: 0.30), width: 1.4),
),
@@ -2302,9 +2327,14 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
Widget _notice(String text, Color accent) {
return Container(
width: double.infinity,
margin: EdgeInsets.symmetric(horizontal: _bandPad),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.10),
color: ColorConstants.tint(
accent,
0.10,
on: ColorConstants.daylightSurface,
),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Row(