production
This commit is contained in:
@@ -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 ──
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user