Miler rider app: surface system, visible design language, backend lifecycle

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:qr_flutter/qr_flutter.dart';
@@ -10,7 +11,7 @@ import 'package:miler/views/helpers/constants/Colorconstants.dart';
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/page_transitions.dart';
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// COLLECT PAYMENT — money taken at the door on the shipper's behalf.
@@ -119,7 +120,7 @@ extension _MethodUi on _Method {
: 'Customer scans and pays';
IconData get icon =>
this == _Method.cash ? Icons.payments_rounded : Icons.qr_code_2_rounded;
this == _Method.cash ? LucideIcons.banknote : LucideIcons.qrCode;
Color get accent => this == _Method.cash
? ColorConstants.acceptGreen
@@ -289,11 +290,12 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
}
void _declinePayment() async {
final ok = await showModalBottomSheet<bool>(
context: context,
sheetAnimationStyle: kMilerSheetStyle,
backgroundColor: Colors.transparent,
isScrollControlled: true,
// Through the kit. The route flags here were already the right ones by
// hand — transparent, scroll-controlled, the app's entrance — which is
// exactly the problem a presenter solves: four correct copies are four
// places for the fifth to be written wrong.
final ok = await showMilerSheet<bool>(
context,
builder: (_) => _ConfirmSheet(
title: 'Customer is not paying?',
body:
@@ -310,7 +312,6 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
/// See [AppFeedback].
void _toast(String message) => AppFeedback.error(context, message);
static String _fmt(double v) =>
v == v.roundToDouble() ? v.round().toString() : v.toStringAsFixed(2);
@@ -326,7 +327,10 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
backgroundColor: ColorConstants.pureSurface,
// 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('Collect payment', onBack: () => Navigator.pop(context)),
appBar: milerPageBar(
'Collect payment',
onBack: () => Navigator.pop(context),
),
body: Column(
children: [
Expanded(
@@ -376,7 +380,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
'DUE AT THIS STOP',
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
@@ -390,7 +394,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
'₹${_fmt(_due)}',
style: TextStyle(
fontSize: 42.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
letterSpacing: -1.6,
height: 1.05,
color: ColorConstants.slateText,
@@ -405,7 +409,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
@@ -456,7 +460,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
label,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: strong ? FontWeight.w800 : FontWeight.w600,
fontWeight: strong ? FontWeight.w700 : FontWeight.w500,
color: strong
? ColorConstants.slateText
: ColorConstants.secondaryText,
@@ -468,7 +472,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
value,
style: TextStyle(
fontSize: strong ? 14.sp : 12.5.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
color: strong
? ColorConstants.slateText
: ColorConstants.secondaryText,
@@ -496,17 +500,16 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
Widget _orderCard() {
final facts = <(IconData, String, String)>[
if (_orderId.isNotEmpty)
(Icons.receipt_long_rounded, 'Consignment', _orderId),
if (_shipper.isNotEmpty)
(Icons.storefront_rounded, 'On behalf of', _shipper),
(LucideIcons.receiptText, 'Consignment', _orderId),
if (_shipper.isNotEmpty) (LucideIcons.store, 'On behalf of', _shipper),
if (_parcels > 0)
(
_kind.icon,
_parcels == 1 ? 'Parcel' : 'Parcels',
'$_parcels collected',
),
if (_weight.isNotEmpty) (Icons.scale_rounded, 'Weight', '$_weight kg'),
if (_itemNote.isNotEmpty) (Icons.notes_rounded, 'Noted', _itemNote),
if (_weight.isNotEmpty) (LucideIcons.weight, 'Weight', '$_weight kg'),
if (_itemNote.isNotEmpty) (LucideIcons.stickyNote, 'Noted', _itemNote),
];
return _card(
@@ -529,7 +532,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
style: TextStyle(
fontSize: 12.sp,
height: 1.35,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
@@ -555,7 +558,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
@@ -570,7 +573,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
@@ -598,7 +601,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
_field(
controller: _reference,
hint: 'UPI reference (optional)',
icon: Icons.tag_rounded,
icon: LucideIcons.tag,
keyboard: TextInputType.number,
formatters: [FilteringTextInputFormatter.digitsOnly],
),
@@ -628,7 +631,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
'Show this to the customer',
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
color: ColorConstants.upiPurple,
fontFamily: FontConstants.fontFamily,
),
@@ -666,7 +669,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
@@ -677,7 +680,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
@@ -719,7 +722,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
m.label,
style: TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
@@ -728,7 +731,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
m.blurb,
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
@@ -737,9 +740,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
),
),
Icon(
selected
? Icons.radio_button_checked_rounded
: Icons.radio_button_unchecked_rounded,
selected ? LucideIcons.circleDot : LucideIcons.circle,
size: 20.sp,
color: selected ? m.accent : ColorConstants.borderStrong,
),
@@ -787,7 +788,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
_field(
controller: _received,
hint: 'Amount you actually took',
icon: Icons.currency_rupee_rounded,
icon: LucideIcons.indianRupee,
keyboard: const TextInputType.numberWithOptions(decimal: true),
formatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')),
@@ -797,7 +798,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
_field(
controller: _shortReason,
hint: 'Why? e.g. customer had no change',
icon: Icons.notes_rounded,
icon: LucideIcons.stickyNote,
maxLines: 2,
capitalization: TextCapitalization.sentences,
),
@@ -831,11 +832,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.account_balance_wallet_rounded,
size: 17.sp,
color: ColorConstants.tertiary,
),
Icon(LucideIcons.wallet, size: 17.sp, color: ColorConstants.tertiary),
SizedBox(width: 10.w),
Expanded(
child: Text(
@@ -844,7 +841,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
style: TextStyle(
fontSize: 12.sp,
height: 1.4,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: ColorConstants.tertiary,
fontFamily: FontConstants.fontFamily,
),
@@ -878,7 +875,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
@@ -893,7 +890,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
'Customer cannot pay',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: ColorConstants.errorRed,
fontFamily: FontConstants.fontFamily,
),
@@ -938,7 +935,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
'Finish the details above',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
@@ -962,7 +959,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
@@ -973,7 +970,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
color: ColorConstants.pureSurface,
shape: BoxShape.circle,
),
child: Icon(Icons.check_rounded, size: 22.sp, color: _method.accent),
child: Icon(LucideIcons.check, size: 22.sp, color: _method.accent),
),
),
);
@@ -991,7 +988,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
'Collect payment',
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
@@ -1004,7 +1001,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle_rounded,
LucideIcons.circleCheck,
size: 54.sp,
color: ColorConstants.acceptGreen,
),
@@ -1013,7 +1010,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
'Nothing to collect here',
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
@@ -1025,7 +1022,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
style: TextStyle(
fontSize: 13.sp,
height: 1.4,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
@@ -1033,7 +1030,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
SizedBox(height: 20.h),
MilerButton(
label: 'Continue',
icon: Icons.arrow_forward_rounded,
icon: LucideIcons.arrowRight,
color: ColorConstants.acceptGreen,
onPressed: () => Navigator.pop(context, <String, dynamic>{
'paid': true,
@@ -1066,7 +1063,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
title,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
@@ -1101,7 +1098,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
color: selected ? Colors.white : ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
@@ -1128,7 +1125,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
onChanged: (_) => setState(() {}),
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
@@ -1174,7 +1171,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
),
child: Row(
children: [
Icon(Icons.info_rounded, size: 16.sp, color: accent),
Icon(LucideIcons.info, size: 16.sp, color: accent),
SizedBox(width: 8.w),
Expanded(
child: Text(
@@ -1182,7 +1179,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
style: TextStyle(
fontSize: 12.sp,
height: 1.35,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: accent,
fontFamily: FontConstants.fontFamily,
),
@@ -1208,65 +1205,38 @@ class _ConfirmSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Container(
margin: EdgeInsets.all(14.r),
padding: EdgeInsets.all(18.r),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 7.h),
Text(
body,
style: TextStyle(
fontSize: 13.sp,
height: 1.45,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 18.h),
Row(
children: [
Expanded(
child: MilerButton(
label: 'Go back',
variant: MilerButtonVariant.outlined,
color: ColorConstants.slateText,
height: ButtonSizes.secondary,
onPressed: () => Navigator.pop(context, false),
),
// The kit's fabric — this was a floating white card with margins on all
// sides, the one sheet in the app not anchored to the bottom edge.
return MilerSheetScaffold(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
MilerSheetHeader(title: title, subtitle: body),
SizedBox(height: 18.h),
Row(
children: [
Expanded(
child: MilerButton(
label: 'Go back',
variant: MilerButtonVariant.outlined,
color: ColorConstants.slateText,
height: ButtonSizes.secondary,
onPressed: () => Navigator.pop(context, false),
),
SizedBox(width: 10.w),
Expanded(
child: MilerButton(
label: confirmLabel,
color: ColorConstants.errorRed,
height: ButtonSizes.secondary,
onPressed: () => Navigator.pop(context, true),
),
),
SizedBox(width: 10.w),
Expanded(
child: MilerButton(
label: confirmLabel,
color: ColorConstants.errorRed,
height: ButtonSizes.secondary,
onPressed: () => Navigator.pop(context, true),
),
],
),
],
),
),
],
),
],
),
);
}

View File

@@ -0,0 +1,710 @@
part of 'pickups.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// CLOSING A DELIVERY
///
/// The three ways a drop can end, and the one place that writes them.
///
/// ── Why this is not the confirmation sheet ──
///
/// The delivery leg used to run through the pickup flow's screens: the map
/// asked the rider to press **I've arrived**, which opened a sheet offering
/// *Picked up · Skipped · Not picked*. Both halves were wrong on a round he is
/// already carrying. Arrival at a door is not a state anything records — there
/// is no endpoint for it (see [MilkRun.deliveryArrivalIsLocalOnly]) — so
/// pressing it wrote nothing and existed only to reveal the next screen. And
/// the screen it revealed asked him to confirm a *pickup* for a bag in his box.
///
/// So the delivery leg has no arrival step and no sheet. The stop is simply
/// **active** once he sets off, and the only question left is how it ended.
///
/// ── Why the writes live here and not in the control that calls them ──
///
/// The confirmation sheet already knew how to close a stop, and putting a
/// second copy behind a new button would be two implementations of one
/// business action — the thing that lets a delivery recorded from one screen
/// differ from the same delivery recorded from another. The control is a
/// control; this is what it does.
/// ─────────────────────────────────────────────────────────────────────────
enum DeliveryOutcome {
/// Handed over. `POST /miler/consignments/:id/deliver`.
delivered,
/// Not this time — a return visit. `POST /miler/consignments/:id/skip`
/// increments `attemptcount` rather than failing the parcel.
skipped,
/// It is not going to happen. `POST /miler/bookings/:id/cancel`.
cancelled,
}
extension DeliveryOutcomeX on DeliveryOutcome {
String get label => switch (this) {
DeliveryOutcome.delivered => 'Delivered',
DeliveryOutcome.skipped => 'Skip',
DeliveryOutcome.cancelled => 'Cancelled',
};
IconData get icon => switch (this) {
DeliveryOutcome.delivered => LucideIcons.circleCheck,
// A clock, not ⏭. `skip_next` is a media-player transport glyph — "jump
// to the next track" — and on a stop it implied the order is passed
// over. A skip here is a RETURN VISIT ("keep it on board and try again
// later"), and the clock is the app's own later-glyph, the same one the
// skip-reason sheet's "Delivery paused" row already wears.
DeliveryOutcome.skipped => LucideIcons.clock,
DeliveryOutcome.cancelled => LucideIcons.circleX,
};
Color get colour => switch (this) {
DeliveryOutcome.delivered => ColorConstants.acceptGreen,
DeliveryOutcome.skipped => ColorConstants.warning,
DeliveryOutcome.cancelled => ColorConstants.errorRed,
};
/// What Activity will show once the round is over.
String get pastTense => switch (this) {
DeliveryOutcome.delivered => 'Delivered',
DeliveryOutcome.skipped => 'Skipped',
DeliveryOutcome.cancelled => 'Cancelled',
};
}
/// Last resort: ask the backend what this booking became.
///
/// Returns '' when it has become nothing, which is a real answer — the booking
/// was never converted — and not a failure to look.
Future<String> _consignmentIdFromBackend(Map<String, dynamic> stop) async {
final bookingId = (stop['bookingid'] ?? stop['orderheaderid'] ?? '')
.toString()
.trim();
if (bookingId.isEmpty) return '';
try {
final assignmentId = await AssignmentLookup.idForBooking(bookingId);
if (assignmentId == null) return '';
final res = await MilerApi.assignment(assignmentId);
if (!res.ok) return '';
String found = '';
void walk(dynamic node, int depth) {
if (found.isNotEmpty || depth > 4 || node == null) return;
if (node is List) {
for (final item in node) {
walk(item, depth + 1);
}
return;
}
if (node is! Map) return;
for (final key in const [
'consignmentid',
'consignmentId',
'consignment_id',
]) {
final v = node[key];
if (v != null && v is! Map && v is! List) {
final str = v.toString().trim();
if (str.isNotEmpty && str != '0' && str != 'null') {
found = str;
return;
}
}
}
for (final v in node.values) {
walk(v, depth + 1);
}
}
walk(res.data, 0);
if (found.isEmpty) walk(res.raw, 0);
if (found.isNotEmpty) {
debugPrint(
'[DELIVERY] booking $bookingId → consignment $found (assignment)',
);
}
return found;
} catch (e) {
debugPrint('[DELIVERY] consignment lookup failed for $bookingId: $e');
return '';
}
}
/// The consignment this stop delivers against, from four sources in order of
/// freshness — or '' when the booking genuinely was never converted.
///
/// ── Why this is a function and not a lookup ──
///
/// `deliver` and `skip` key on the CONSIGNMENT, which only exists after
/// `pickup-complete` — and **`GET /miler/bookings` does not return it at
/// all** (verified against the live API on 2026-08-20: 22 rows, zero
/// `consignment*` keys). So the row a rider works from *never* carries the id
/// by itself, and any path that reads `stop['consignmentid']` raw refuses
/// every delivery on a fresh session. That was exactly the Update Status
/// sheet's bug: `closeDelivery` had this chain inline while the sheet read
/// the raw key, so the same door could be closed from one screen and
/// "Could not record delivered" from the other. One resolver, every caller.
///
/// 1. the row itself, once a previous resolution has written it back;
/// 2. the id recorded at the pivot on this device, under any of the three
/// keys the pivot has historically filed it under — order id first, that
/// is the key the current write uses;
/// 3. the assignment row (`GET /miler/assignments/:id`), the one record
/// that spans both halves of the job — it knows the booking and, once
/// converted, what the booking became. One extra call at the door beats
/// refusing the rider;
/// 4. nothing, which is a real state — the pickup never converted — and gets
/// a real message rather than a network error.
///
/// A found id is written back onto the row and into the local record, so the
/// rest of the session — and the skip path — get it for free.
Future<String> resolveConsignmentId(Map<String, dynamic> stop) async {
final String orderId = (stop['orderid'] ?? '').toString().trim();
String consignmentId = (stop['consignmentid'] ?? '').toString().trim();
if (consignmentId.isEmpty) {
final ids = await getConsignmentIds();
for (final key in <String>[
orderId,
(stop['pickupid'] ?? '').toString().trim(),
(stop['bookingid'] ?? '').toString().trim(),
]) {
if (key.isEmpty) continue;
final found = (ids[key] ?? '').trim();
if (found.isNotEmpty) {
consignmentId = found;
break;
}
}
}
if (consignmentId.isEmpty) {
consignmentId = await _consignmentIdFromBackend(stop);
}
if (consignmentId.isNotEmpty) {
stop['consignmentid'] = consignmentId;
if (orderId.isNotEmpty) {
await rememberConsignmentId(orderId, consignmentId);
}
}
return consignmentId;
}
/// Asks the server to release one consignment. True when it is on the road.
///
/// ── Two servers, one button ──
///
/// `Collected_By_Miler` and `POST /consignments/:id/start-delivery` are built
/// but **gated off** on the backend (`MILER_COLLECTED_STATE_ENABLED`, default
/// off) until this app ships the press. So both worlds are live at once and
/// this has to be right in each:
///
/// flag OFF `pickup-complete` releases hyperlocal work itself, exactly as
/// before. The consignment is already `Out_for_Delivery` when it
/// reaches this tab, and there is nothing to call.
/// flag ON the pivot stops at `Collected_By_Miler` and this press is what
/// puts the load on the road.
///
/// The row itself now says which — `consignmentstatus` ships on every
/// `GET /miler/bookings` row since 21 Aug 2026. So the common path costs no
/// round trip at all, and with the flag off the app never fires a
/// `start-delivery` that can only be refused: a stream of 4xx per rider press
/// is not a harmless no-op, it is the backend's error log.
///
/// Falls back to asking the consignment when the row does not say, which is
/// also what answers the two refusals a real round produces — `INVALID_STATE`
/// (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.
Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
final consignmentId = await resolveConsignmentId(stop);
if (consignmentId.isEmpty) {
debugPrint('[MILKRUN] no consignment id for ${MilkRun.idOf(stop)}');
return false;
}
// What the list row already told us, before spending a request on it.
var state = consignmentStateFromRaw(stop['consignmentstatus']);
if (state == ConsignmentState.unknown) {
state = await ConsignmentGate.stateOf(consignmentId);
}
if (state.isDeliverable || state.isDelivered) return true;
if (!state.needsRelease && state != ConsignmentState.unknown) {
debugPrint('[MILKRUN] $consignmentId is ${state.name} — not releasable');
return false;
}
final res = await MilerApi.startDelivery(consignmentId);
if (res.ok) return true;
debugPrint(
'[MILKRUN] start-delivery $consignmentId -> '
'${res.status} ${res.code} ${res.message}',
);
// 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;
}
/// Brings this device into line with a delivery the server already has.
///
/// The same local bookkeeping a successful `deliver` performs — the order
/// leaves the rider's hands, its consignment mapping is forgotten, and it is
/// filed as finished — **without** posting anything. Nothing here invents a
/// state: the state was read from the consignment's own history first.
Future<void> _reconcileAlreadyDone(
Map<String, dynamic> stop,
_MyPickupsState? parentState,
) async {
final orderId = (stop['orderid'] ?? '').toString();
try {
if (orderId.isNotEmpty) {
await removeCollectedOrderIds([orderId]);
await forgetConsignmentIds([orderId]);
}
// The same path a real completion takes — one recorder, so a reconciled
// stop and a delivered one produce the same Activity record.
parentState?.markPickupFinished(stop);
await addCompletedBookings([stop], terminalStatus: 'delivered');
} catch (e) {
debugPrint('[DELIVERY] reconcile failed for $orderId: $e');
}
}
/// Writes [outcome] against [stop], then clears the local bookkeeping so the
/// order leaves Deliveries and lands in Activity.
///
/// Returns the outcome payload the caller should pop with, or null when the
/// write failed — in which case the rider keeps the stop and the screen, which
/// is the only honest thing to do with work the hub has not been told about.
/// One press, one write.
///
/// The delivery path had no shared guard — only a widget's `_isNavigating`
/// flag, which is per-widget and dies with a rebuild, and the outcome is
/// reachable from a sheet that can be re-opened. Two Delivered taps meant two
/// `deliver` posts against a backend that is not idempotent about them. Keyed
/// on the resource and the verb, so every route to the same stop shares it.
Future<Map<String, dynamic>?> closeDelivery(
BuildContext context, {
required Map<String, dynamic> stop,
required _MyPickupsState? parentState,
required DeliveryOutcome outcome,
String notes = '',
String proofPath = '',
}) {
final key = '${outcome.name}:${(stop['orderid'] ?? '').toString()}';
return MutationGuard.run<Map<String, dynamic>?>(
key,
() => _closeDelivery(
context,
stop: stop,
parentState: parentState,
outcome: outcome,
notes: notes,
proofPath: proofPath,
),
);
}
Future<Map<String, dynamic>?> _closeDelivery(
BuildContext context, {
required Map<String, dynamic> stop,
required _MyPickupsState? parentState,
required DeliveryOutcome outcome,
String notes = '',
/// Where the doorstep photo was saved, `''` when the rider completed
/// without one. Carried onto the finished record so the Activity detail
/// page can show it — see [ProofStore] for why it is not sent as
/// `photourl`.
String proofPath = '',
}) async {
final dc = Get.put(PickupsController(), permanent: true);
final String orderId = (stop['orderid'] ?? '').toString();
final int pickupIdInt = int.tryParse('${stop['pickupid'] ?? 0}') ?? 0;
final int orderHeaderId = int.tryParse('${stop['orderheaderid'] ?? 0}') ?? 0;
double parseD(dynamic v) {
if (v == null) return 0.0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString()) ?? 0.0;
}
// The customer's door. On a milk run the stop *is* the customer's address, so
// the pickup coordinates stand in when there is no separate drop point —
// same fallback the sheet uses, for the same reason.
final double dropLat = parseD(stop['droplat'] ?? stop['DropLat'] ?? 0);
final double dropLng = parseD(stop['droplon'] ?? stop['DropLon'] ?? 0);
final double stopLat = parseD(stop['pickuplat'] ?? stop['PickupLat'] ?? 0);
final double stopLng = parseD(stop['pickuplon'] ?? stop['PickupLon'] ?? 0);
final String consignmentId = await resolveConsignmentId(stop);
// ── Ask the consignment what it is, before writing to it ──
//
// ROOT CAUSE, verified against the live API 2026-08-21. `deliver` answers
// `400 consignment is not out for delivery` for TWO opposite situations —
// "not released yet" and "already delivered" — and the app was treating
// both as an error to show the rider. Consignment 34's own history
// (`GET /miler/consignments/logs/34`) reads:
//
// Out_for_Delivery "Package collected by miler and converted…"
// Delivered "Delivered to SEQTEST Ukkadam at (…)"
//
// …so `pickup-complete` DOES release hyperlocal work (no
// `/miler/deliveries/start` is involved, and none exists), and that stop had
// been delivered an hour earlier. It stayed on the Deliveries tab because
// the only thing the app reads is the BOOKING status, which is terminal at
// `Converted_To_Consignment` and never learns about the delivery half — and
// the local "I delivered this" memory had gone with a reinstall.
//
// So the state is read from the one route that reports it, and the answer
// decides. See [ConsignmentGate].
//
// Skip asks the same question and gets a different answer to one of the
// rungs: the backend widened `skip` on 21 Aug 2026 so a failed attempt is
// reportable from `Collected_By_Miler` as well as `Out_for_Delivery` — a
// customer who is not home is not home whether or not the rider remembered
// to press Start round. See [ConsignmentStateX.canSkip].
final gated =
outcome == DeliveryOutcome.delivered ||
outcome == DeliveryOutcome.skipped;
if (gated && consignmentId.isNotEmpty) {
final state = await ConsignmentGate.stateOf(consignmentId);
if (outcome == DeliveryOutcome.skipped && state.canSkip) {
// Nothing to correct and nothing to release — post it.
} else {
switch (ConsignmentGate.gateFor(state)) {
case DeliverGate.alreadyDelivered:
// The work is done server-side; this device is what is behind. File
// it and drop it from the tab — showing an error for a delivery the
// rider genuinely completed is the bug, not the fix.
debugPrint('[DELIVERY] $orderId already delivered — reconciling');
await _reconcileAlreadyDone(stop, parentState);
if (context.mounted) {
AppFeedback.success(context, 'Already delivered — record updated');
}
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');
if (context.mounted) {
AppFeedback.error(
context,
'This parcel is still with the hub. It can be delivered once the '
'hub releases it.',
);
}
return null;
case DeliverGate.needsRelease:
// Collected, but nobody pressed **Start round** — a rider who left
// straight from the kitchen, or a press that failed on a dead signal.
//
// He is standing at the door. The release is his own to make and the
// route exists, so it is made here rather than sending him back two
// screens to press a button whose only purpose is to make this one
// work. Nothing is assumed: the server performs the transition and
// the state is read again before the delivery is posted.
debugPrint('[DELIVERY] $orderId not started — releasing now');
if (!await releaseForDelivery(stop) ||
!(await ConsignmentGate.stateOf(consignmentId)).isDeliverable) {
if (context.mounted) {
AppFeedback.error(
context,
'This delivery has not been started yet. Open Deliveries and '
'press Start round, then try again.',
);
}
return null;
}
break;
case DeliverGate.closed:
debugPrint('[DELIVERY] $orderId is closed — refusing');
if (context.mounted) {
AppFeedback.error(
context,
'This order was closed by the office and cannot be delivered.',
);
}
return null;
case DeliverGate.deliverable:
case DeliverGate.unknown:
// `unknown` falls through on purpose: a failed read is not evidence,
// and the server is still the judge one call later.
break;
}
}
}
if (outcome != DeliveryOutcome.cancelled && consignmentId.isEmpty) {
// Cancel is a *booking* route, so it is the one outcome that still works
// without a consignment.
//
// 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
// message says the actionable thing rather than naming an internal id the
// rider has no way to obtain.
// ── Two different failures wore one sentence ──
//
// This always said "never picked up on the system — mark it picked up
// from Home first", which is true for exactly one of the two ways to get
// here, and actively misleading for the other:
//
// • The booking really has NOT been collected. The advice is right.
// • The booking IS `Converted_To_Consignment` — collected, converted,
// sitting on the Deliveries tab — and the app simply cannot obtain the
// consignment id. Telling that rider to "pick it up from Home" sends
// him to look for a stop that is not on Home, to redo work he has
// already done, and it cannot possibly clear the error.
//
// Verified against production 2026-08-21, booking 59
// (`DM-BK-501CB551-45130`, status `Converted_To_Consignment`): every route
// to its consignment id is a dead end — `GET /miler/bookings` returns no
// consignment key on any of its 29 rows, and `GET /miler/assignments`
// returns only 12 rows covering 12 of those bookings, so
// `AssignmentLookup` has nothing to resolve. `?status=`/`?bookingid=`
// filters are ignored by that endpoint. There is no fifth route.
//
// So the rider is told what is actually true and who can fix it, and the
// gap is logged with the evidence rather than blamed on him. The real fix
// is backend — see MILER_API_REQUIREMENTS.md request 3.
final collected =
stopStatusOf(stop).isPicked || stopStatusOf(stop).isDeliveryLeg;
debugPrint('[DELIVERY] $orderId has no consignment id — refusing');
ApiConfig.logGap(
'deliver',
collected
? 'booking $orderId is collected but its consignment id is '
'unreachable: the row carries none, the local pivot record is '
'absent, and GET /miler/assignments does not list this '
'booking. Backend must return consignmentid on /miler/bookings.'
: 'booking $orderId reached the delivery leg with no consignment: '
'its pickup-complete either never ran or did not convert it.',
);
if (context.mounted) {
AppFeedback.error(
context,
collected
? "This order's delivery reference is missing, so it cannot be "
'completed from the app. Ask your hub 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.',
);
}
return null;
}
// One fix for whichever branch runs, taken before the write so a slow GPS
// cannot delay the call the rider is waiting on. Null is fine: every route
// here treats coordinates as optional telemetry, not as a condition.
Position? riderFix;
try {
riderFix = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
timeLimit: Duration(seconds: 5),
),
);
} catch (_) {
// No fix, and the outcome still has to be recordable. See above.
}
bool ok = false;
try {
switch (outcome) {
case DeliveryOutcome.delivered:
ok = await dc.updateDeliveredStatus(
pickupId: pickupIdInt,
consignmentId: consignmentId,
deliveredToName:
(stop['dropcustomer'] ??
stop['pickupcustomer'] ??
stop['PickupCustomer'] ??
'Customer')
.toString(),
dropLat: (dropLat != 0 ? dropLat : stopLat).toStringAsFixed(6),
dropLng: (dropLng != 0 ? dropLng : stopLng).toStringAsFixed(6),
notes: notes,
);
case DeliveryOutcome.skipped:
// ── Straight to the consignment route ──
//
// Not through `PickupsController.updateSkippedStatus`, which takes an
// `int pickupId` and posts the *pickup* skip. A consignment id is not
// guaranteed to be numeric — it can be a tracking reference — so
// squeezing it through an int parameter would either lose it or, worse,
// 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,
);
ok = res.ok;
if (!ok) debugPrint('[DELIVERY][skip] ${res.status} ${res.message}');
case DeliveryOutcome.cancelled:
ok = await dc.updateCancelledStatus(
pickupId: pickupIdInt,
orderHeaderId: orderHeaderId,
pickupLat: (dropLat != 0 ? dropLat : stopLat).toStringAsFixed(6),
pickupLng: (dropLng != 0 ? dropLng : stopLng).toStringAsFixed(6),
notes: notes.isEmpty ? 'Cancelled by rider' : notes,
);
}
} catch (e) {
debugPrint('[DELIVERY][${outcome.name}] $orderId failed: $e');
ok = false;
}
if (!ok) {
// ── Refused is not failed, and neither one may close a stop ──
//
// Completing locally after a write that never landed is what made the app
// show a finished screen while the office still had the order live. The
// rider keeps the stop and gets told why.
if (context.mounted) {
AppFeedback.error(
context,
dc.lastBlockedReason ??
'Could not record ${outcome.pastTense.toLowerCase()} — try again',
);
}
return null;
}
// ── Did the skip actually close anything? Ask, do not assume ──
//
// A delivery closes its consignment: the server says `Delivered` and the row
// stops being delivery work on its own. **A skip is a failed attempt**, and
// what the server does with one is the server's business — it may move the
// consignment to a failure state, or it may quite correctly leave it
// `Out_for_Delivery` for a second attempt or an RTO decision made elsewhere.
//
// Those two need opposite handling, and the app cannot tell them apart from
// the skip's own 200. So it reads the consignment afterwards:
//
// server closed it → a terminal record, filed in Activity, off this tab.
// server kept it open → **parked**, not closed. The stop stays visible
// under SKIPPED with its reason, the parcel stays in
// the rider's hands, and its consignment mapping is
// kept because it is still actionable.
//
// Writing a terminal local record over a consignment the hub still calls
// open is the failure this avoids: two systems disagreeing about whether a
// parcel is somebody's problem, with the rider's screen the only one saying
// it is not. See MILER_API_REQUIREMENTS.md request 14 — there is no route
// that reports a *failed attempt* as an outcome, which is why this has to be
// inferred at all.
var stillOpen = false;
if (outcome == DeliveryOutcome.skipped) {
final after = consignmentId.isEmpty
? ConsignmentState.unknown
: await ConsignmentGate.stateOf(consignmentId);
// The rule, and why unknown counts as open, is on the state itself.
stillOpen = after.isOpenAfterSkip;
debugPrint(
'[DELIVERY][skip] $orderId -> consignment is ${after.name}, '
'${stillOpen ? 'still open — parking' : 'closed — filing'}',
);
if (stillOpen) {
ApiConfig.logGap(
'skip',
'consignment $consignmentId is still ${after.name} after a successful '
'skip: the backend exposes no failed-attempt outcome, so the app '
'parks the stop locally rather than inventing a terminal state.',
);
}
}
// Local bookkeeping, so the order leaves this tab immediately rather than at
// the next poll: the write has landed, and a row that lingers reads as the
// button having done nothing.
try {
if (stillOpen) {
// Parked, not finished. The parcel is still in his hands and the
// consignment is still writable, so neither record is cleared — and the
// completed store, which is what holds a stop off this tab for good, is
// deliberately not written.
await addSkippedBooking({
...stop,
if (proofPath.isNotEmpty) 'proofphotopath': proofPath,
}, reason: notes);
parentState?.markOrderAsSkipped(stop, notes);
unawaited(WorkRepository.instance.invalidate());
return {
'outcome': outcome.name,
'isDelivery': true,
'deliveryOutcome': outcome.name,
'parked': true,
'notes': notes,
};
}
if (orderId.isNotEmpty) {
// It is no longer in his hands, whichever way it ended.
await removeCollectedOrderIds([orderId]);
// And nothing else will be posted against its consignment.
await forgetConsignmentIds([orderId]);
}
// Records it for Activity and drops it from this tab in one call — the same
// path every other finished stop takes. `cancelled` covers both non-delivery
// outcomes, which is what Activity renders as "not completed".
parentState?.markPickupFinished(
{
...stop,
'orderstatus': outcome == DeliveryOutcome.delivered
? 'delivered'
: outcome.name,
// Why he walked away, carried onto the record — Activity has nowhere
// else to get it, and "Skipped" with no reason is half a record.
if (outcome == DeliveryOutcome.skipped && notes.isNotEmpty)
'skipreason': notes,
// The doorstep photo rides onto the record, which is the only place it
// is ever read from — Activity's detail page draws it from here. Absent
// when there is none, so a record without proof has no key rather than
// an empty one that reads as a broken image.
if (proofPath.isNotEmpty) 'proofphotopath': proofPath,
},
cancelled: outcome != DeliveryOutcome.delivered,
// ── A skip is filed as a skip, and it does not come back ──
//
// It used to be flattened into `cancelled`, which put a stop the rider
// walked away from into the same slice as one the office called off.
// Filed under its own word it lands in Activity's *Active* slice, which
// is exactly what it is: work that still owes a return visit.
//
// Reached only when the server agrees the consignment is closed. The
// completed store is what [_MyPickupsState._restoreClosedToday] reads,
// so this stop leaves the tab and stays off it — which is only honest
// because the hub says the same thing.
terminalStatus: outcome == DeliveryOutcome.skipped ? 'skipped' : null,
);
unawaited(ProofStore.prune());
} catch (e) {
debugPrint('[DELIVERY][${outcome.name}] cleanup: $e');
}
// One copy of the day, and it is now stale — Home, Deliveries and Activity
// must all re-read rather than each deciding for itself. See [WorkRepository].
unawaited(WorkRepository.instance.invalidate());
return {
'outcome': outcome == DeliveryOutcome.delivered
? 'completed'
: outcome.name,
'isDelivery': true,
'deliveryOutcome': outcome.name,
'notes': notes,
};
}

View File

@@ -0,0 +1,358 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:image_picker/image_picker.dart';
import 'package:miler/data/proof_store.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
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';
/// ─────────────────────────────────────────────────────────────────────────
/// PROOF OF DELIVERY
///
/// The last screen of a delivery: take the photo, look at it, hand over.
///
/// ── Why capture and commit share one page ──
///
/// The obvious build is a camera sheet that pops straight back and completes
/// the stop. It is also the one that loses parcels: the rider never sees what
/// he captured, so a thumb over the lens, a dark hallway or a blurred frame
/// becomes the evidence — and he finds out weeks later, when the office is
/// asking about a parcel he cannot prove he handed over.
///
/// So the photo is *shown*, full width, and **Mark as delivered** sits under
/// it on the same page. One glance answers "is that good enough?", Retake is
/// one tap, and the commit is a deliberate second act rather than a side
/// effect of the shutter.
///
/// ── The camera opens itself ──
///
/// The rider arrived here by choosing Delivered; asking him to press another
/// button to start the thing he already asked for is a tap that buys nothing.
/// If he cancels the camera he lands on the empty state, which explains
/// itself and offers the shutter again — and, because a broken camera must
/// never trap somebody at a door, a way to complete the stop without one.
///
/// Returns the saved proof path on success, `''` when the rider completed
/// without a photo, and null when he backed out entirely — three outcomes the
/// caller can tell apart.
/// ─────────────────────────────────────────────────────────────────────────
class DeliveryProofPage extends StatefulWidget {
/// Names the stop on the page, so the rider can see he is closing the right
/// one before he commits.
final String customer;
/// Keys the stored photo. See [ProofStore].
final String orderId;
const DeliveryProofPage({
super.key,
required this.customer,
required this.orderId,
});
@override
State<DeliveryProofPage> createState() => _DeliveryProofPageState();
}
class _DeliveryProofPageState extends State<DeliveryProofPage> {
String? _shotPath;
bool _busy = false;
bool _cameraFailed = false;
@override
void initState() {
super.initState();
// After the first frame, so the page is on screen behind the camera.
WidgetsBinding.instance.addPostFrameCallback((_) => _capture());
}
Future<void> _capture() async {
if (_busy) return;
setState(() => _busy = true);
try {
final shot = await ImagePicker().pickImage(
source: ImageSource.camera,
// The same ceiling the crate photo uses: enough to identify a doorway
// and a parcel, small enough to keep on a phone with no storage left.
imageQuality: 70,
maxWidth: 1280,
);
if (!mounted) return;
if (shot != null) setState(() => _shotPath = shot.path);
} catch (e) {
debugPrint('[PROOF] camera failed: $e');
if (mounted) setState(() => _cameraFailed = true);
} finally {
if (mounted) setState(() => _busy = false);
}
}
/// Copies the shot somewhere durable and hands the path back.
Future<void> _confirm() async {
if (_busy) return;
setState(() => _busy = true);
final saved = _shotPath == null
? null
: await ProofStore.save(widget.orderId, _shotPath!);
if (!mounted) return;
// 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 ?? '');
}
@override
Widget build(BuildContext context) {
final hasShot = _shotPath != null;
return Scaffold(
backgroundColor: ColorConstants.daylightSurface,
appBar: const MilerAppBar(title: 'Proof of delivery', showBack: true),
body: SafeArea(
top: false,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_who(),
SizedBox(height: 16.h),
hasShot ? _preview() : _empty(),
],
),
),
),
_actions(hasShot),
],
),
),
);
}
/// 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),
child: Row(
children: [
Container(
width: 40.w,
height: 40.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.acceptGreen.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Icon(
LucideIcons.user,
size: 20.sp,
color: ColorConstants.acceptGreen,
),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Handing over to',
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 2.h),
Text(
widget.customer.isEmpty ? 'Customer' : widget.customer,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
],
),
);
/// 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,
),
),
),
),
),
),
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.
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,
),
),
],
),
);
/// The commit, and the way to change your mind about the picture.
///
/// **Mark as delivered** is the page's only filled control and it is green,
/// the colour this app gives to advancing the job. It stays enabled with no
/// photo: a camera that will not open, a dead battery on the flash, a
/// customer who objects — none of those are reasons to strand a rider at a
/// door with a parcel he has handed over. What changes is the *label under
/// it*, so completing without proof is a visible choice rather than a
/// silent one.
Widget _actions(bool hasShot) => Container(
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 16.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
border: Border(top: BorderSide(color: ColorConstants.borderSubtle)),
),
child: Column(
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,
),
),
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),
Text(
'No photo attached to this delivery',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.warning,
fontFamily: FontConstants.fontFamily,
),
),
],
],
),
);
}

View File

@@ -349,30 +349,57 @@ class _PickupsDoneState extends State<PickupsDone>
'$_remainingCount ${_remainingCount == 1 ? 'stop' : 'stops'} left',
];
return Row(
children: [
Icon(
isCancelled ? Icons.cancel_rounded : Icons.check_circle_rounded,
size: 16.sp,
color: isCancelled
? ColorConstants.errorRed
: ColorConstants.acceptGreen,
// ── It still has to answer "did that register?" ──
//
// The reasoning above is right that this is completed information and must
// not out-rank the next stop. It went one step too far: a 16sp tick beside
// 12.5sp grey text is the same treatment as every other caption on the
// screen, so the one line confirming the act the rider just performed
// looked like metadata about it.
//
// A tinted capsule — the app's own state vocabulary, the same one the route
// chips and the sheet's secondary actions use — gives it an edge and its
// semantic colour back without giving it size. It is still shorter and
// quieter than the next-stop hero underneath, which is the ranking that
// matters.
final Color tone = isCancelled
? ColorConstants.errorRed
: ColorConstants.acceptGreen;
return Align(
alignment: AlignmentDirectional.centerStart,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 7.h),
decoration: BoxDecoration(
color: tone.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
SizedBox(width: 7.w),
Expanded(
child: Text(
parts.join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isCancelled ? LucideIcons.circleX : LucideIcons.circleCheck,
size: 18.sp,
color: tone,
),
),
SizedBox(width: 8.w),
Flexible(
child: Text(
parts.join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: tone,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
],
),
);
}
@@ -389,7 +416,8 @@ class _PickupsDoneState extends State<PickupsDone>
/// asks what he will be doing when he arrives. A saturated chip at the top
/// asked that question first and in the loudest voice on the screen.
Widget _nextStopHero(Map<String, dynamic> stop) {
final kind = stopKindOf(stop);
// The leg, not the payload — see [MilkRun.workingKind].
final kind = MilkRun.workingKind(stop);
final name = (stop['pickupcustomer'] ?? 'Stop').toString();
final address = (stop['pickupaddress'] ?? 'Address not available')
.toString();
@@ -408,7 +436,7 @@ class _PickupsDoneState extends State<PickupsDone>
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 28.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
height: 1.08,
color: ColorConstants.slateText,
@@ -450,7 +478,7 @@ class _PickupsDoneState extends State<PickupsDone>
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: kind.accent,
fontFamily: FontConstants.fontFamily,
),
@@ -478,11 +506,7 @@ class _PickupsDoneState extends State<PickupsDone>
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.replay_rounded,
size: 15.sp,
color: ColorConstants.warning,
),
Icon(LucideIcons.rotateCcw, size: 15.sp, color: ColorConstants.warning),
SizedBox(width: 8.w),
Expanded(
child: Text(
@@ -490,7 +514,7 @@ class _PickupsDoneState extends State<PickupsDone>
style: TextStyle(
fontSize: 12.5.sp,
height: 1.35,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: ColorConstants.warning,
fontFamily: FontConstants.fontFamily,
),
@@ -541,7 +565,8 @@ class _PickupsDoneState extends State<PickupsDone>
}
Widget _upNextRow(Map<String, dynamic> stop, int position) {
final kind = stopKindOf(stop);
// The leg, not the payload — see [MilkRun.workingKind].
final kind = MilkRun.workingKind(stop);
final name = (stop['pickupcustomer'] ?? 'Stop').toString();
final address = (stop['pickupaddress'] ?? '').toString();
@@ -562,8 +587,8 @@ class _PickupsDoneState extends State<PickupsDone>
child: Text(
'$position',
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w800,
fontSize: 13.sp,
fontWeight: FontWeight.w700,
color: kind.accent,
fontFamily: FontConstants.fontFamily,
),
@@ -580,7 +605,7 @@ class _PickupsDoneState extends State<PickupsDone>
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
@@ -605,7 +630,7 @@ class _PickupsDoneState extends State<PickupsDone>
),
SizedBox(width: 8.w),
Icon(
Icons.chevron_right_rounded,
LucideIcons.chevronRight,
size: 20.sp,
color: ColorConstants.secondaryText,
),
@@ -636,7 +661,7 @@ class _PickupsDoneState extends State<PickupsDone>
'Finding your next stop…',
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
@@ -666,7 +691,7 @@ class _PickupsDoneState extends State<PickupsDone>
shape: BoxShape.circle,
),
child: Icon(
Icons.warehouse_rounded,
LucideIcons.warehouse,
size: 30.sp,
color: ColorConstants.acceptGreen,
),
@@ -677,7 +702,7 @@ class _PickupsDoneState extends State<PickupsDone>
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
@@ -725,8 +750,16 @@ class _PickupsDoneState extends State<PickupsDone>
? 'Loading…'
: hasNext
? 'Move to next stop'
: 'Return to hub',
icon: hasNext ? Icons.arrow_forward_rounded : Icons.warehouse_rounded,
// 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'
: 'Finish my round'),
icon: hasNext
? LucideIcons.arrowRight
: (ServiceProfile.active.endsAtHub
? LucideIcons.warehouse
: LucideIcons.house),
color: hasNext ? ColorConstants.acceptGreen : ColorConstants.primary,
loading: _loadingNextOrders,
onPressed: _loadingNextOrders ? null : () => _goToNextStop(),
@@ -738,16 +771,19 @@ class _PickupsDoneState extends State<PickupsDone>
/// Section labels. Small, letterspaced, grey — the quietest way to name a
/// group, and the reason none of the groups needs an outline.
TextStyle get _eyebrow => TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.3,
// 11 -> 12.5, and less tracking. A letterspaced 11sp all-caps label is the
// least readable combination in the app, on the screen a rider glances at
// between two stops.
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0.9,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
);
TextStyle get _metaStyle => TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
fontSize: 13.5.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
);
@@ -779,11 +815,7 @@ class _PickupsDoneState extends State<PickupsDone>
color: ColorConstants.rewardAmber,
shape: BoxShape.circle,
),
child: Icon(
Icons.card_giftcard_rounded,
size: 30.sp,
color: Colors.white,
),
child: Icon(LucideIcons.gift, size: 30.sp, color: Colors.white),
),
),
@@ -796,7 +828,7 @@ class _PickupsDoneState extends State<PickupsDone>
'You Won a Bonus!',
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.w900,
fontWeight: FontWeight.w800,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
@@ -869,7 +901,7 @@ class _PickupsDoneState extends State<PickupsDone>
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.monetization_on_rounded,
LucideIcons.circleDollarSign,
size: 64.sp,
color: const Color(0xFFFFB800),
),
@@ -878,7 +910,7 @@ class _PickupsDoneState extends State<PickupsDone>
'${widget.bonusPoints}',
style: TextStyle(
fontSize: 52.sp,
fontWeight: FontWeight.w900,
fontWeight: FontWeight.w800,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
letterSpacing: -2,
@@ -888,7 +920,7 @@ class _PickupsDoneState extends State<PickupsDone>
'Bonus Points',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
@@ -909,7 +941,7 @@ class _PickupsDoneState extends State<PickupsDone>
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.touch_app_rounded,
LucideIcons.pointer,
color: ColorConstants.onSurfaceVariant,
size: 16.sp,
),
@@ -945,8 +977,16 @@ class _PickupsDoneState extends State<PickupsDone>
Widget _buildDoneButton({required bool isEnabled}) {
final bool hasNext = _nextOrders.isNotEmpty;
return MilerButton(
label: hasNext ? 'Move to next stop' : 'Return to hub',
icon: hasNext ? Icons.arrow_forward_rounded : Icons.warehouse_rounded,
label: hasNext
? 'Move to next stop'
: (ServiceProfile.active.endsAtHub
? 'Return to hub'
: 'Finish my round'),
icon: hasNext
? LucideIcons.arrowRight
: (ServiceProfile.active.endsAtHub
? LucideIcons.warehouse
: LucideIcons.house),
color: hasNext ? ColorConstants.acceptGreen : ColorConstants.primary,
onPressed: isEnabled ? () => _goToNextStop() : null,
);

File diff suppressed because it is too large Load Diff

View File

@@ -26,13 +26,16 @@ class MapViewRow extends StatelessWidget {
swipeToGoBack: false,
);
},
// White, because this sits on the brand app bar — see [MilerAppBar].
// Maroon-on-maroon made the control invisible the moment the bar stopped
// being a frosted wash over white.
child: Image.asset(
'assets/images/customermap.png',
color: ColorConstants.primary,
color: Colors.white,
height: 32,
width: 32,
errorBuilder: (c, e, s) =>
Icon(Icons.map, size: 32, color: ColorConstants.primary),
const Icon(LucideIcons.map, size: 32, color: Colors.white),
),
);
}

View File

@@ -491,15 +491,24 @@ class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen>
}
void _showCustomerDetailsSheet(Map<String, dynamic> pickup, int stepNumber) {
showModalBottomSheet(
context: context,
sheetAnimationStyle: kMilerSheetStyle,
isScrollControlled: true,
isDismissible: true,
enableDrag: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
// ── Through the kit ──
//
// The furthest-drifted sheet in the app, and every difference was a
// decision re-made rather than inherited:
//
// • its own `RoundedRectangleBorder(20)` — a radius nothing else uses;
// • **no glass at all**, so it arrived opaque white over a live map while
// every other sheet is frosted;
// • `SafeArea(bottom: true)` **and** `Padding(bottom: viewInsets.bottom)`
// — the two summed, which is the double-inset bug written out in full:
// with the keyboard up the content floats a navigation bar's height
// above it;
// • a 24 gutter against the app's 20;
// • a 22sp/w600 title in **brand red**, where the system's header is
// 18/w700 slate — the accent belongs on the badge, not on the sentence;
// • and a close ✕, which the handle and the barrier already do.
showMilerSheet<void>(
context,
builder: (BuildContext context) {
final customerName = (pickup['pickupcustomer'] ?? 'Customer')
.toString();
@@ -511,267 +520,226 @@ class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen>
.trim();
final isSkipped = status == 'skipped';
return SafeArea(
top: false,
left: false,
right: false,
bottom: true,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
padding: const EdgeInsets.all(24),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
return MilerSheetScaffold(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const MilerSheetHeader(
title: 'Customer details',
icon: LucideIcons.mapPinned,
),
SizedBox(height: 4.h),
// Step number and status
Row(
children: [
// Header with title and close icon
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Customer Details',
style: TextStyle(
fontSize: 22.sp,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.primary,
),
),
),
InkWell(
onTap: () => Navigator.of(context).pop(),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: ColorConstants.dividerColor,
shape: BoxShape.circle,
),
// A close affordance, not a destructive action.
// This was a 34px red `Icons.cancel` — the loudest
// thing in the sheet, competing with the call button
// and reading as "delete this stop".
child: Icon(
Icons.close_rounded,
size: 20.sp,
color: ColorConstants.slateText,
),
),
),
],
),
const Divider(thickness: 1.5),
// Step number and status
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: isSkipped
? ColorConstants.warning
: ColorConstants.primary,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Text(
'Step $stepNumber',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14.sp,
),
),
),
if (isSkipped) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: ColorConstants.warning,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Text(
'SKIPPED',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12.sp,
),
),
),
],
],
),
const SizedBox(height: 16),
// Customer name
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.person,
size: 20,
color: ColorConstants.secondaryText,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Customer Name',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
customerName,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Pickup address
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.location_on,
size: 20,
color: ColorConstants.secondaryText,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Recipient Address',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
address,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Phone number
if (phone.isNotEmpty)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.phone,
size: 20,
color: ColorConstants.secondaryText,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Contact Number',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
phone,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
),
),
],
),
),
],
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
const SizedBox(height: 20),
// Booking ID
Text(
'Booking ID: $orderId',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontStyle: FontStyle.italic,
decoration: BoxDecoration(
color: isSkipped
? ColorConstants.warning
: ColorConstants.primary,
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
),
child: Text(
'Step $stepNumber',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 14.sp,
),
),
),
const SizedBox(height: 20),
// Call button
MilerButton(
label: 'Call',
icon: Icons.phone,
color: ColorConstants.acceptGreen,
onPressed: () async {
// Close bottom sheet first
Navigator.of(context).pop();
// Small delay to ensure bottom sheet is closed
await Future.delayed(const Duration(milliseconds: 300));
// Then make the call
final bool success = await launchPhoneDialer(phone);
if (!success && context.mounted) {
AppFeedback.error(context, 'Could not open the dialer');
}
},
),
const SizedBox(height: 8),
if (isSkipped) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: ColorConstants.warning,
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
),
child: Text(
'SKIPPED',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12.sp,
),
),
),
],
],
),
),
const SizedBox(height: 16),
// Customer name
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
LucideIcons.user,
size: 20,
color: ColorConstants.secondaryText,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Customer Name',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
customerName,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 18.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Pickup address
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
LucideIcons.mapPin,
size: 20,
color: ColorConstants.secondaryText,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Recipient Address',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
address,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 18.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Phone number
if (phone.isNotEmpty)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
LucideIcons.phone,
size: 20,
color: ColorConstants.secondaryText,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Contact Number',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
phone,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 18.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
),
),
],
),
),
],
),
const SizedBox(height: 20),
// Booking ID
Text(
'Booking ID: $orderId',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 20),
// Call button
MilerButton(
label: 'Call',
icon: LucideIcons.phone,
color: ColorConstants.acceptGreen,
onPressed: () async {
// Close bottom sheet first
Navigator.of(context).pop();
// Small delay to ensure bottom sheet is closed
await Future.delayed(const Duration(milliseconds: 300));
// Then make the call
final bool success = await launchPhoneDialer(phone);
if (!success && context.mounted) {
AppFeedback.error(context, 'Could not open the dialer');
}
},
),
const SizedBox(height: 8),
],
),
),
);
@@ -823,35 +791,17 @@ class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen>
// Inverted, and rebuilt as an ordinary AppBar: white surface, brand red on
// the title where it costs nothing, a real `leading` slot, and the same
// hairline the rest of the app uses to close a bar.
appBar: AppBar(
// The app-wide frosted brand wash — see [milerGlassSurface].
backgroundColor: Colors.transparent,
flexibleSpace: milerGlassSurface(),
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
titleSpacing: 0,
leading: IconButton(
tooltip: 'Back',
icon: Icon(
Icons.arrow_back_rounded,
color: ColorConstants.slateText,
size: 22.sp,
),
onPressed: () => Navigator.pop(context),
),
title: Text(
'Route map',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 19.sp,
color: ColorConstants.primary,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
),
),
centerTitle: false,
),
// ── The sixth app-bar implementation, folded into the one ──
//
// This was a hand-rolled `AppBar`: a frosted wash, a maroon 19sp title
// and its own back button — so the map page's heading was a different
// colour and a different size from every other pushed screen. The audit
// note on [milerPageBar] says that bar is the only one a pushed screen
// gets; this one had simply never been converted.
//
// Now it inherits the brand ground, the white title and the shared back
// control along with everything else, and cannot drift again.
appBar: milerPageBar('Route map', onBack: () => Navigator.pop(context)),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: AfterEntrance(

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
@@ -161,7 +162,7 @@ class _PaymentScreenState extends State<PaymentScreen>
],
),
child: Icon(
Icons.check_rounded,
LucideIcons.check,
color: Colors.white,
size: 52.sp,
),
@@ -173,7 +174,7 @@ class _PaymentScreenState extends State<PaymentScreen>
'Payment Collected Successfully',
style: TextStyle(
fontSize: 26.sp,
fontWeight: FontWeight.w900,
fontWeight: FontWeight.w800,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
@@ -230,7 +231,7 @@ class _PaymentScreenState extends State<PaymentScreen>
),
),
child: Icon(
Icons.receipt_long_rounded,
LucideIcons.receiptText,
color: ColorConstants.primary,
size: 18.sp,
),
@@ -244,7 +245,7 @@ class _PaymentScreenState extends State<PaymentScreen>
'Payment Receipt',
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
@@ -355,7 +356,7 @@ class _PaymentScreenState extends State<PaymentScreen>
value,
style: TextStyle(
fontSize: 14.sp,
fontWeight: valueBold ? FontWeight.w700 : FontWeight.w600,
fontWeight: valueBold ? FontWeight.w600 : FontWeight.w500,
color: valueColor ?? ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
@@ -406,7 +407,7 @@ class _PaymentScreenState extends State<PaymentScreen>
shape: BoxShape.circle,
),
child: Icon(
Icons.arrow_back_ios_new_rounded,
LucideIcons.chevronLeft,
color: Colors.white,
size: 16.sp,
),
@@ -417,7 +418,7 @@ class _PaymentScreenState extends State<PaymentScreen>
'Payment Collection',
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
letterSpacing: 0.5,
@@ -451,7 +452,7 @@ class _PaymentScreenState extends State<PaymentScreen>
'₹${_totalAmount.toStringAsFixed(0)}',
style: TextStyle(
fontSize: 36.sp,
fontWeight: FontWeight.w900,
fontWeight: FontWeight.w800,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
letterSpacing: -1,
@@ -482,7 +483,7 @@ class _PaymentScreenState extends State<PaymentScreen>
'#$_orderId',
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
@@ -566,7 +567,7 @@ class _PaymentScreenState extends State<PaymentScreen>
SizedBox(height: 10.h),
_buildPaymentOption(
Icons.money_rounded,
LucideIcons.banknote,
'Cash',
'Pay with cash on collection',
'cash',
@@ -574,7 +575,7 @@ class _PaymentScreenState extends State<PaymentScreen>
),
SizedBox(height: 10.h),
_buildPaymentOption(
Icons.qr_code_scanner_rounded,
LucideIcons.scanLine,
'QR / UPI',
'Scan QR code to pay',
'upi',
@@ -582,7 +583,7 @@ class _PaymentScreenState extends State<PaymentScreen>
),
SizedBox(height: 10.h),
_buildPaymentOption(
Icons.credit_card_rounded,
LucideIcons.creditCard,
'Card',
'Swipe / tap card terminal',
'card',
@@ -597,7 +598,9 @@ class _PaymentScreenState extends State<PaymentScreen>
height: ButtonSizes.primary,
decoration: BoxDecoration(
color: ColorConstants.primary,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
borderRadius: BorderRadius.circular(
DesignConstants.radiusXl,
),
),
child: const Center(
child: SizedBox(
@@ -611,7 +614,9 @@ class _PaymentScreenState extends State<PaymentScreen>
),
)
: ClipRRect(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
borderRadius: BorderRadius.circular(
DesignConstants.radiusXl,
),
child: Container(
color: ColorConstants.primary,
child: SliderButton(
@@ -637,7 +642,7 @@ class _PaymentScreenState extends State<PaymentScreen>
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
@@ -649,7 +654,7 @@ class _PaymentScreenState extends State<PaymentScreen>
shape: BoxShape.circle,
),
child: Icon(
Icons.payments_rounded,
LucideIcons.banknote,
size: 22.sp,
color: ColorConstants.primary,
),
@@ -685,7 +690,7 @@ class _PaymentScreenState extends State<PaymentScreen>
label,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
letterSpacing: 0.5,
@@ -754,7 +759,7 @@ class _PaymentScreenState extends State<PaymentScreen>
title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
@@ -784,7 +789,7 @@ class _PaymentScreenState extends State<PaymentScreen>
),
),
child: isSelected
? Icon(Icons.check_rounded, color: Colors.white, size: 14.sp)
? Icon(LucideIcons.check, color: Colors.white, size: 14.sp)
: null,
),
],
@@ -809,7 +814,7 @@ class _PaymentScreenState extends State<PaymentScreen>
style: TextStyle(
fontSize: isTotal ? 15.sp : 13.sp,
color: ColorConstants.onSurfaceVariant,
fontWeight: isBold ? FontWeight.w600 : FontWeight.normal,
fontWeight: isBold ? FontWeight.w500 : FontWeight.normal,
fontFamily: FontConstants.fontFamily,
),
),
@@ -818,8 +823,8 @@ class _PaymentScreenState extends State<PaymentScreen>
style: TextStyle(
fontSize: isTotal ? 17.sp : 14.sp,
fontWeight: isTotal
? FontWeight.w800
: (isBold ? FontWeight.w700 : FontWeight.w500),
? FontWeight.w700
: (isBold ? FontWeight.w600 : FontWeight.w500),
color: isTotal
? ColorConstants.primary
: ColorConstants.onSurface,

View File

@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:image_picker/image_picker.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
@@ -175,7 +176,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
SizedBox(height: 20.h),
_buildSectionHeader(
icon: Icons.camera_alt_rounded,
icon: LucideIcons.camera,
title: _isDelivery ? 'Delivery Photo' : 'Parcel Photo',
badge: 'Required',
isComplete: _photoVerified,
@@ -189,7 +190,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
// a first-mile pickup, so it was pure friction on every stop.
if (!_isDelivery) ...[
_buildSectionHeader(
icon: Icons.inventory_2_rounded,
icon: LucideIcons.package,
title: 'Parcel Details (Optional)',
isComplete: _weightController.text.isNotEmpty,
),
@@ -199,7 +200,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
],
_buildSectionHeader(
icon: Icons.lock_outline_rounded,
icon: LucideIcons.lock,
title: _isDelivery ? 'Delivery OTP' : 'Pickup Code',
badge: 'Required',
isComplete: _otpController.text.isNotEmpty,
@@ -219,7 +220,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
).copyWith(
counterText: '',
prefixIcon: Icon(
Icons.lock_outline_rounded,
LucideIcons.lock,
color: ColorConstants.secondaryText,
size: 20.sp,
),
@@ -255,7 +256,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
leading: IconButton(
tooltip: 'Back',
icon: Icon(
Icons.arrow_back_ios_new_rounded,
LucideIcons.chevronLeft,
color: ColorConstants.onSurface,
size: 20.sp,
),
@@ -271,7 +272,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
style: TextStyle(
fontSize: 21.sp,
color: ColorConstants.onSurface,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
),
),
@@ -337,7 +338,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
),
),
child: Icon(
done ? Icons.check_rounded : Icons.circle_outlined,
done ? LucideIcons.check : LucideIcons.circle,
color: done ? Colors.white : ColorConstants.secondaryText,
size: 16.sp,
),
@@ -347,7 +348,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
label,
style: TextStyle(
fontSize: 10.sp,
fontWeight: done ? FontWeight.w700 : FontWeight.w500,
fontWeight: done ? FontWeight.w600 : FontWeight.w500,
color: done
? _successGreen
: ColorConstants.onSurfaceVariant,
@@ -411,7 +412,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
),
),
@@ -426,7 +427,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
_customerName,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
@@ -452,7 +453,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
),
SizedBox(height: 12.h),
_buildLocationRow(
Icons.location_on_rounded,
LucideIcons.mapPin,
_pickupAddress,
_isDelivery ? 'DELIVERY' : 'PICKUP',
ColorConstants.warning.withValues(alpha: 0.40),
@@ -488,7 +489,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
label,
style: TextStyle(
fontSize: 9.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: accentColor,
letterSpacing: 0.8,
fontFamily: FontConstants.fontFamily,
@@ -529,7 +530,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Icon(
isComplete ? Icons.check_circle_rounded : icon,
isComplete ? LucideIcons.circleCheck : icon,
size: 16.sp,
color: isComplete ? _successGreen : ColorConstants.onSurfaceVariant,
),
@@ -540,7 +541,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
@@ -557,7 +558,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
badge,
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: _primaryRed,
fontFamily: FontConstants.fontFamily,
),
@@ -622,7 +623,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
child: Row(
children: [
Icon(
Icons.check_circle_rounded,
LucideIcons.circleCheck,
color: Colors.white,
size: 16.sp,
),
@@ -649,7 +650,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
shape: BoxShape.circle,
),
child: Icon(
Icons.check_rounded,
LucideIcons.check,
color: Colors.white,
size: 14.sp,
),
@@ -668,7 +669,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
shape: BoxShape.circle,
),
child: Icon(
Icons.camera_alt_rounded,
LucideIcons.camera,
color: ColorConstants.secondaryText,
size: 36.sp,
),
@@ -678,7 +679,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
'Tap to take parcel photo',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
@@ -737,7 +738,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
label,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
@@ -749,7 +750,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
style: TextStyle(
fontSize: 13.sp,
color: _primaryRed,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
),
),
@@ -790,7 +791,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.lock_outline_rounded,
LucideIcons.lock,
color: ColorConstants.secondaryText,
size: 18.sp,
),
@@ -805,7 +806,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
: 'Complete all fields to continue',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
@@ -843,7 +844,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
@@ -855,7 +856,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
shape: BoxShape.circle,
),
child: Icon(
Icons.check_rounded,
LucideIcons.check,
size: 22.sp,
color: ColorConstants.primary,
),

File diff suppressed because it is too large Load Diff

View File

@@ -112,7 +112,7 @@ class _PipInfoCardState extends State<PipInfoCard> {
'Out',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w600,
color: ColorConstants.primary,
fontFamily: FontConstants.fontFamily,
),
@@ -133,7 +133,7 @@ class _PipInfoCardState extends State<PipInfoCard> {
strokeCap: StrokeCap.round,
textStyle: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w600,
color: ColorConstants.primary,
fontFamily: FontConstants.fontFamily,
),
@@ -157,7 +157,7 @@ class _PipInfoCardState extends State<PipInfoCard> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),

View File

@@ -158,9 +158,31 @@ class RouteMetricsHelper {
Map<String, dynamic> stop, {
double? riderLat,
double? riderLng,
// ── Which end of the journey is "the stop"? ──
//
// This always read the pickup pair, which on the delivery leg measures
// the ride BACK to the kitchen the rider just left — the number grew as
// he approached the customer's door. The caller knows which leg it is
// rendering (the queue asks `MilkRun.workingKind`); this function cannot,
// so the leg arrives as a flag. Default false keeps every pickup-leg
// caller — Home's cards, the group nodes — exactly as it was.
//
// A delivery row whose payload carries no drop pair still falls back to
// the pickup pair: on a hyperlocal round the stop IS the customer's
// address in many payloads, and a wrong-ish number beats a dash the
// rider cannot plan with.
bool toDrop = false,
}) {
final stopLat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
final stopLng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
double stopLat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
double stopLng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
if (toDrop) {
final dLat = _toDouble(stop['droplat'] ?? stop['DropLat']);
final dLng = _toDouble(stop['droplon'] ?? stop['DropLon']);
if (dLat != 0 && dLng != 0) {
stopLat = dLat;
stopLng = dLng;
}
}
final fromLat = (riderLat != null && riderLat != 0)
? riderLat

View File

@@ -18,6 +18,23 @@ class _PickupBottomSheet extends StatefulWidget {
class _PickupBottomSheetState extends State<_PickupBottomSheet>
with SingleTickerProviderStateMixin {
/// Orders the rider is carrying, as the queue screen knows them.
///
/// "Picked up" is not a status here — the accepted store normalises these
/// rows to `accepted`, and the hand-over is recorded in the collected set. So
/// the leg has to be asked with that set in hand or a bag already in the box
/// reads as still needing collection. See [MilkRun.workingKind].
Set<String> get _carrying =>
(widget.parentState ?? context.findAncestorStateOfType<_MyPickupsState>())
?._collectedIds ??
const <String>{};
/// Where this stop's *delivery* goes, or null when it carries no coordinates
/// for that leg. Read from [MilkRun] so this sheet, the route row and the map
/// screen cannot disagree about the destination.
({double lat, double lng})? get _deliveryTarget =>
MilkRun.navigationTarget(widget.pickup, collectedIds: _carrying);
String selectedOption = '';
bool isPickupsSelected = true;
bool showOptions = false;
@@ -73,20 +90,17 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
lastFailureTime = null;
}
bool _hasCollection() {
final raw = widget.pickup['collectionamt'];
if (raw == null) return false;
if (raw is num) return raw > 0;
final parsed = double.tryParse(raw.toString()) ?? 0.0;
return parsed > 0;
}
// Both of these read the amount through [stopCollectionAmount] rather than
// reaching into the map themselves.
//
// They used to parse `collectionamt` inline, which meant they missed the
// `pickupamt` / `codamount` spellings the shared helper accepts — this sheet
// could show "No collection" on a stop the card behind it had priced. It is
// also the gate that retires cash entirely for a subscription tenant, and a
// second reader would have walked straight past it.
bool _hasCollection() => stopCollectionAmount(widget.pickup) > 0;
double _getCollectionAmount() {
final raw = widget.pickup['collectionamt'];
if (raw == null) return 0.0;
if (raw is num) return raw.toDouble();
return double.tryParse(raw.toString()) ?? 0.0;
}
double _getCollectionAmount() => stopCollectionAmount(widget.pickup);
double _getPickupAmount() {
final raw = widget.pickup['pickupamt'];
@@ -102,31 +116,41 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
required Color color,
required VoidCallback onTap,
}) {
// ── Tonal, not outlined ──
//
// Three bordered buttons stacked under one filled button is four rectangles
// competing on a sheet whose question has one likely answer. The rider
// delivered it; the other two are what he reaches for when something went
// wrong.
//
// A tinted fill with no border is the same treatment the state chips on the
// route use, so "amber means parked, red means it did not happen" is one
// vocabulary across the app rather than a per-screen invention — and it
// drops an edge from a surface that already has plenty.
return SizedBox(
height: ButtonSizes.secondary,
child: OutlinedButton.icon(
child: TextButton.icon(
onPressed: onTap,
icon: Icon(icon, color: color, size: 18.sp),
icon: Icon(icon, color: color, size: 19.sp),
label: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
fontSize: 14.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: color,
fontFamily: FontConstants.fontFamily,
),
),
style: OutlinedButton.styleFrom(
side: BorderSide(color: color.withValues(alpha: 0.4)),
style: TextButton.styleFrom(
backgroundColor: color.withValues(alpha: 0.10),
foregroundColor: color,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ButtonSizes.radius),
),
// The theme gives OutlinedButton `vertical: 16` for full-width form
// buttons. In a 48dp box that leaves a 16px content band, which was
// painting this row's 18sp icon at 16px — measured, and silent.
padding: EdgeInsets.symmetric(horizontal: 14.w),
padding: EdgeInsets.symmetric(horizontal: 12.w),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
@@ -138,7 +162,15 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
Widget build(BuildContext context) {
// Delivery stops are prepaid: no payment step, the primary action is
// "Delivered" (a straight confirm) rather than "Picked up" + payment.
final bool isDelivery = stopKindOf(widget.pickup).isDelivery;
//
// Asked of the *leg*, not the payload. The booking adapter stamps
// `type: pickup` on every row, so a milk-run order already in the rider's
// box still called itself a pickup — and this sheet asked him to confirm
// collecting something he was carrying. See [MilkRun.workingKind].
final bool isDelivery = MilkRun.workingKind(
widget.pickup,
collectedIds: _carrying,
).isDelivery;
// Only the cancel path asks a question here now. Money is asked about once,
// on the payment page — see [_onPickedUp].
@@ -148,12 +180,32 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
? ColorConstants.acceptGreen
: ColorConstants.errorRed;
// ── Name the thing the button actually does ──
//
// On logistics this press is not "confirm a pickup". It posts
// `pickup-complete`, which converts the booking into a live consignment
// with a tracking number and routes it to a hub — the shipment begins to
// exist here, and the rider is the one who starts it. The app has always
// done that and never said so, which is why the flow reads as five
// unrelated forms rather than one order being raised.
//
// A milk-run drop is a hand-over and keeps its own word; anything else
// stays "Confirm pickup".
final String confirmLabel = isDelivery
? 'Confirm delivery'
: (ServiceProfile.active.initiatesShipment
? 'Initiate order'
: 'Confirm pickup');
final String modeLabel = isPickupsSelected
? (isDelivery ? 'Delivered' : 'Picked up')
: (isDelivery ? 'Undelivered' : 'Cancelled');
// "Cancelled" was the rider cancelling a *booking*, which is the hub's
// word for it and not his. What happened at the door is that he did not
// get the parcel — see the action row below.
: (isDelivery ? 'Not delivered' : 'Not picked');
final IconData modeIcon = isPickupsSelected
? Icons.check_circle_rounded
: Icons.cancel_rounded;
? LucideIcons.circleCheck
: LucideIcons.circleX;
// A reason list belongs to the cancel path alone. A completed pickup has
// nothing left to ask: the payment page has already taken the method, the
@@ -175,17 +227,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
mainAxisSize: MainAxisSize.min,
children: [
// ── Handle + Header ─────────────────────────────────────
Center(
child: Container(
width: 40.w,
height: 4.h,
decoration: BoxDecoration(
color: ColorConstants.borderSubtle,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
),
),
SizedBox(height: 14.h),
const MilerSheetHandle(),
Row(
children: [
@@ -198,18 +240,20 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
),
decoration: BoxDecoration(
color: confirmColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
borderRadius: BorderRadius.circular(
DesignConstants.radiusXl,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(modeIcon, color: confirmColor, size: 14.sp),
SizedBox(width: 5.w),
Icon(modeIcon, color: confirmColor, size: 15.sp),
SizedBox(width: 6.w),
Text(
modeLabel,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w700,
fontSize: 12.5.sp,
fontWeight: FontWeight.w600,
color: confirmColor,
fontFamily: FontConstants.fontFamily,
),
@@ -221,16 +265,23 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
],
Expanded(
child: Text(
// ── This sheet is the confirmation page now ──
//
// Nothing opens before it any more: the rider taps "I've
// arrived" on the map and lands here. So the heading
// names the stop he is closing rather than announcing a
// status update, and the three answers underneath are
// the whole of what the app asks him at a door.
showOptions
? (isPickupsSelected
? (isDelivery
? 'Confirm delivery'
: 'Collect payment')
: 'Select reason')
: 'Update Status',
: 'What happened?')
: confirmLabel,
style: TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.onSurface,
),
@@ -253,13 +304,11 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
child: Container(
padding: EdgeInsets.all(8.r),
decoration: BoxDecoration(
color: ColorConstants.neutralLight,
color: MilerSurface.canvas,
shape: BoxShape.circle,
),
child: Icon(
showOptions
? Icons.arrow_back_rounded
: Icons.close_rounded,
showOptions ? LucideIcons.arrowLeft : LucideIcons.x,
color: ColorConstants.onSurface,
size: 18.sp,
),
@@ -280,21 +329,62 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
// Three clear choices, one tap each. Primary = Picked up /
// Delivered; then Skip and Cancel / Undelivered.
if (showSlider) ...[
// Primary: Picked up / Delivered
// ── Navigate, on the sheet the rider is actually looking at ──
//
// The delivery leg is the one where he is driving to an
// address he has never been to, and re-opening maps meant
// dismissing this sheet, finding the button on the screen
// behind it, and coming back. Every delivery app a rider has
// used keeps the journey action on the live-stop surface for
// exactly that reason.
//
// Only on the delivery leg: a collection is worked in bulk at
// one counter from Home, where the kitchen card carries its
// own Navigate.
//
// Hidden — not disabled — when the stop has no coordinates for
// this leg: a Navigate that opens a map on nothing is worse
// than none, because the rider only finds out after
// committing to follow it. Same rule as the route row's.
if (isDelivery && _deliveryTarget != null) ...[
SizedBox(
width: double.infinity,
height: ButtonSizes.secondary,
child: MilerButton(
label: 'Navigate',
icon: LucideIcons.navigation,
color: ColorConstants.primary,
height: ButtonSizes.secondary,
onPressed: () {
final t = _deliveryTarget!;
openExternalNavigation(t.lat, t.lng);
},
),
),
SizedBox(height: 10.h),
],
// Primary: Picked up / Delivered.
//
// The three outcomes are the whole vocabulary of a stop:
// **Picked up** (he has it), **Not picked** (he was there and
// did not get it — the sheet then asks why), **Skipped** (he
// is parking it for a return visit). One tap chooses; nothing
// else is asked of him at the door.
SizedBox(
width: double.infinity,
height: ButtonSizes.primary,
child: ElevatedButton.icon(
onPressed: () => _onPickedUp(isDelivery),
icon: const Icon(
Icons.check_circle_rounded,
LucideIcons.circleCheck,
color: Colors.white,
),
label: Text(
isDelivery ? 'Delivered' : 'Picked up',
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w800,
fontSize: 16.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
@@ -303,7 +393,9 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
backgroundColor: ColorConstants.acceptGreen,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
borderRadius: BorderRadius.circular(
DesignConstants.radiusXl,
),
),
),
),
@@ -314,8 +406,8 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
children: [
Expanded(
child: _sheetSecondaryButton(
label: 'Skip',
icon: Icons.skip_next_rounded,
label: 'Skipped',
icon: LucideIcons.clock,
color: ColorConstants.warning,
onTap: () async {
final resolvedParentState =
@@ -334,8 +426,14 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
SizedBox(width: 10.w),
Expanded(
child: _sheetSecondaryButton(
label: isDelivery ? 'Undelivered' : 'Cancel',
icon: Icons.cancel_rounded,
// "Cancelled" on the delivery leg, by product
// decision, and it is also what the press actually
// does: the reason list behind it posts a cancel. The
// pickup leg keeps "Not picked", which is the honest
// description of standing at a counter and being
// handed nothing.
label: isDelivery ? 'Cancelled' : 'Not picked',
icon: LucideIcons.circleX,
color: ColorConstants.errorRed,
onTap: () {
setState(() {
@@ -376,7 +474,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
child: Column(
children: [
for (var i = 0; i < options.length; i++)
_ReasonRow(
MilerSheetChoiceRow(
label: options[i],
icon: _cancelIcon(options[i]),
accent: confirmColor,
@@ -416,7 +514,9 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
borderRadius: BorderRadius.circular(
DesignConstants.radiusXl,
),
),
),
child: isLoading
@@ -435,8 +535,8 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
children: [
Icon(
isPickupsSelected
? Icons.check_rounded
: Icons.close_rounded,
? LucideIcons.check
: LucideIcons.x,
size: 22.sp,
color: Colors.white,
),
@@ -449,17 +549,15 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
Flexible(
child: Text(
isPickupsSelected
? (isDelivery
? 'Confirm delivery'
: 'Confirm pickup')
? confirmLabel
: (isDelivery
? 'Mark undelivered'
: 'Cancel booking'),
? 'Mark not delivered'
: 'Mark not picked'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
@@ -507,6 +605,30 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
Future<void> _onPickedUp(bool isDelivery) async {
if (isLoading || isProcessing) return;
// ── One press on a service run, two on a parcel ──
//
// The two-step exists because a parcel stop has something to confirm: what
// was collected, whether anything was owed, what the rider is signing off.
// The rider states it, then commits it.
//
// A subscription drop has none of that. He is at a door with one bag that
// is already paid for, and the whole chain is "I've arrived" → "I've picked
// up" → next. Making him press *Delivered* and then *Confirm delivery* is
// the same answer twice, ten times a lunchtime, for a stop that carries no
// decision at all.
if (ServiceProfile.active.handsOffAtCollection) {
setState(() {
isPickupsSelected = true;
selectedOption = isDelivery ? 'Delivered' : 'Picked up';
showOptions = false;
showSlider = false;
isProcessing = false;
lastFailureTime = null;
});
await _handleConfirm();
return;
}
// Prepaid, or nothing to collect: there is no money question to ask, so go
// straight to the deliberate confirm.
if (isDelivery || !_hasCollection()) {
@@ -551,8 +673,8 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
//
// A failure here is worth telling him about, unlike the parcel step: this
// one is his liability.
final bookingId = widget.pickup['pickupid'] ??
widget.pickup['orderheaderid'];
final bookingId =
widget.pickup['pickupid'] ?? widget.pickup['orderheaderid'];
if (bookingId != null) {
final payRes = await UpdatePickupProvider().submitPayment(
bookingId,
@@ -577,11 +699,11 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
IconData _cancelIcon(String option) {
final l = option.toLowerCase();
if (l.contains('refused')) return Icons.person_off_outlined;
if (l.contains('reachable')) return Icons.phone_missed_rounded;
if (l.contains('switched')) return Icons.phone_disabled_rounded;
if (l.contains('location')) return Icons.location_off_rounded;
return Icons.cancel_outlined;
if (l.contains('refused')) return LucideIcons.userX;
if (l.contains('reachable')) return LucideIcons.phoneMissed;
if (l.contains('switched')) return LucideIcons.phoneOff;
if (l.contains('location')) return LucideIcons.mapPinOff;
return LucideIcons.circleX;
}
// ── ALL ORIGINAL BUSINESS LOGIC BELOW — UNTOUCHED ────────────────────────
@@ -625,7 +747,12 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
}
final d = widget.pickup;
final bool isDelivery = stopKindOf(d).isDelivery;
// The same question the labels ask, so the button cannot say one thing and
// post another. See [MilkRun.workingKind].
final bool isDelivery = MilkRun.workingKind(
d,
collectedIds: _carrying,
).isDelivery;
debugPrint('[CONFIRM] ===== PICKUP INFO =====');
debugPrint('[CONFIRM] pickupid: ${d['pickupid']}');
debugPrint('[CONFIRM] orderid: ${d['orderid']}');
@@ -748,26 +875,110 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
// Best-effort API + cleanup — must NOT prevent the success screen from
// showing. With mock/offline data these can throw; we still complete
// the pickup locally and move on to the success screen.
//
// With one exception, below: a stop the app *refused to send* is not a
// stop that failed to send.
try {
apiSuccess = await dc.updatePickedupStatus(
pickupId: pickupIdInt,
orderHeaderId: orderHeaderId,
pickupLocationId: pickupLocationId,
smsPickup: 0,
ridersLat: riderLatStr,
ridersLng: riderLngStr,
pickupLat: pickupLat.toStringAsFixed(6),
pickupLng: pickupLng.toStringAsFixed(6),
notes: selectedOption,
pickupAmount: pickupAmount,
collectionAmount: collectionAmtToSend,
collectedAmount: collectedAmtToSend,
collectionStatus: collectionStatusToSend,
wasSkipped: wasSkipped,
orderId: orderId,
proofImage: '',
);
debugPrint('[PICKUP][CONFIRM] result=$apiSuccess');
// ── Which ending this stop has ──
//
// On a milk run this tab is the rider's *round*, so closing a stop
// here is a hand-over at a customer's door, not a collection: it
// writes `deliver` against the consignment. Sending `pickup-complete`
// instead would re-collect an order that is already in his hands and
// file the crate as the finished job.
//
// A logistics stop closes exactly as it always has.
if (ServiceProfile.active.deliversToCustomer) {
final dropLat = parseD(d['droplat'] ?? d['DropLat'] ?? 0);
final dropLng = parseD(d['droplon'] ?? d['DropLon'] ?? 0);
// ── Resolved, never read raw ──
//
// `GET /miler/bookings` does not return `consignmentid` at all
// (verified live 2026-08-20), so the raw key this used to read
// was empty on every fresh session and `updateDeliveredStatus`
// refused before anything left the phone — the rider pressed
// Delivered and was told "Could not record delivered" with the
// network never touched. [resolveConsignmentId] is the same
// four-source chain `closeDelivery` uses: the row, the pivot's
// local record, then the assignment row on the backend, which
// does carry the id.
final consignmentId = await resolveConsignmentId(d);
if (consignmentId.isEmpty) {
// The pickup never converted this booking — deliver cannot
// exist yet. Same message the queue's own close path shows.
apiSuccess = false;
if (mounted) {
AppFeedback.error(
context,
'This order was never picked up on the system — mark it '
'picked up first, then deliver.',
);
}
} else {
apiSuccess = await dc.updateDeliveredStatus(
pickupId: pickupIdInt,
consignmentId: consignmentId,
deliveredToName:
(d['pickupcustomer'] ?? d['PickupCustomer'] ?? 'Customer')
.toString(),
ridersLat: riderLatStr,
ridersLng: riderLngStr,
// Fall back to the stop's own coordinates when the payload
// carries no separate drop point: on a milk run the stop IS the
// customer's address, so they are the same place.
dropLat: (dropLat != 0 ? dropLat : pickupLat).toStringAsFixed(
6,
),
dropLng: (dropLng != 0 ? dropLng : pickupLng).toStringAsFixed(
6,
),
notes: selectedOption,
);
}
debugPrint('[DELIVERY][CONFIRM] result=$apiSuccess');
} else {
apiSuccess = await dc.updatePickedupStatus(
pickupId: pickupIdInt,
orderHeaderId: orderHeaderId,
pickupLocationId: pickupLocationId,
smsPickup: 0,
ridersLat: riderLatStr,
ridersLng: riderLngStr,
pickupLat: pickupLat.toStringAsFixed(6),
pickupLng: pickupLng.toStringAsFixed(6),
notes: selectedOption,
pickupAmount: pickupAmount,
collectionAmount: collectionAmtToSend,
collectedAmount: collectedAmtToSend,
collectionStatus: collectionStatusToSend,
wasSkipped: wasSkipped,
orderId: orderId,
proofImage: '',
);
debugPrint('[PICKUP][CONFIRM] result=$apiSuccess');
}
// ── Refused, not failed ──
//
// The rider is not at the address, so nothing was sent and nothing
// will be. Completing locally here is what made the app show the
// done screen, drop the stop off Bookings and leave Home holding a
// pickup the hub had never been told about — the rider's phone said
// picked, the office said active, and only the office was right.
//
// He keeps the sheet, the stop and a message saying how far he still
// has to go. See [PickupsController.lastBlockedReason].
if (!apiSuccess && dc.lastBlockedReason != null) {
debugPrint('[PICKUP][CONFIRM] blocked: ${dc.lastBlockedReason}');
lastFailureTime = null;
if (mounted) {
setState(() {
isLoading = false;
isProcessing = false;
});
}
return;
}
await _cachePickupsCancelled(pickupId, orderId, isPickups: true);
await _cleanupAfterCompletion(
@@ -836,6 +1047,22 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
);
debugPrint('[CANCEL][CONFIRM] result=$apiSuccess');
// Same rule as the pickup branch: a proximity block is the app
// declining to send, not the wire failing. ("Incorrect location" is
// exempt from the fence upstream — a rider reporting that the address
// is wrong is by definition not standing at it.)
if (!apiSuccess && dc.lastBlockedReason != null) {
debugPrint('[CANCEL][CONFIRM] blocked: ${dc.lastBlockedReason}');
lastFailureTime = null;
if (mounted) {
setState(() {
isLoading = false;
isProcessing = false;
});
}
return;
}
await _cachePickupsCancelled(pickupId, orderId, isPickups: false);
await _cleanupAfterCompletion(
orderId,
@@ -865,7 +1092,10 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
// the button for four seconds, so a rider whose confirm threw pressed
// it, watched nothing happen, pressed again and was told to wait —
// indistinguishable from a dead button.
AppFeedback.error(context, "Couldn't complete that stop — check your signal and try again");
AppFeedback.error(
context,
"Couldn't complete that stop — check your signal and try again",
);
}
await Future.delayed(const Duration(seconds: 4));
@@ -1004,6 +1234,22 @@ class _StopBrief extends StatelessWidget {
this.showRule = false,
});
/// `Bag 5 · Vidhya Kitchen`, whichever halves exist; the trimmed booking
/// reference only as a last resort.
String _subtitle(String orderId) {
final bag = (pickup['baglabel'] ?? pickup['BagLabel'] ?? '')
.toString()
.trim();
final source = stopSourceName(pickup);
final line = [
if (bag.isNotEmpty) bag,
if (source.isNotEmpty) source,
].join(' \u00b7 ');
if (line.isNotEmpty) return line;
if (orderId.isEmpty) return 'Booking';
return '#${orderId.replaceFirst(RegExp(r'^DM-BK-', caseSensitive: false), '')}';
}
@override
Widget build(BuildContext context) {
final kind = stopKindOf(pickup);
@@ -1052,7 +1298,7 @@ class _StopBrief extends StatelessWidget {
style: TextStyle(
color: kind.accent,
fontSize: 13.5.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
),
),
@@ -1068,20 +1314,30 @@ class _StopBrief extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.5.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 2.h),
// ── The subtitle is what the rider matches, not files ──
//
// This printed `Booking #DM-BK-…` — a routing reference in
// the second-loudest slot on three sheets, saying nothing a
// rider acts on at a door. The line now carries the bag and
// the counter it came off (`Bag 5 · Vidhya Kitchen`) — the
// pairing that stops the wrong lunch reaching the wrong
// door — and falls back to the trimmed reference only when
// the payload offers nothing better. The full id stays one
// tap away on the detail sheet, with its copy control.
Text(
orderId.isNotEmpty ? 'Booking #$orderId' : 'Booking',
_subtitle(orderId),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
@@ -1100,11 +1356,7 @@ class _StopBrief extends StatelessWidget {
// rather than as two things that happen to be near each other.
SizedBox(
width: 40.w,
child: Icon(
Icons.location_on_rounded,
size: 20.sp,
color: kind.accent,
),
child: Icon(LucideIcons.mapPin, size: 20.sp, color: kind.accent),
),
SizedBox(width: 12.w),
Expanded(
@@ -1115,7 +1367,7 @@ class _StopBrief extends StatelessWidget {
kind.locationLabel,
style: TextStyle(
fontSize: 9.5.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: kind.accent,
fontFamily: FontConstants.fontFamily,
@@ -1129,7 +1381,7 @@ class _StopBrief extends StatelessWidget {
style: TextStyle(
fontSize: 13.sp,
height: 1.35,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
@@ -1148,114 +1400,5 @@ class _StopBrief extends StatelessWidget {
}
}
/// ── One answer in a reason list ──
///
/// Update Status and Skip ask the same shape of question — "why?", four or two
/// answers, one of them — and drew it two different ways, both as stacks of
/// outlined white cards on a white sheet with each icon inside a second box.
///
/// A hairline separates, the tick answers, and selection is carried by a fill
/// rather than by a thicker outline. A fill is what survives sunlight; a 1.5px
/// border is what does not — see [ColorConstants.cardSurface].
class _ReasonRow extends StatelessWidget {
final String label;
final IconData icon;
final Color accent;
final bool selected;
/// The hairline above this row. Suppressed either side of the selected row so
/// it does not cut across the tint and turn the one chosen answer back into a
/// box.
final bool showRule;
final VoidCallback? onTap;
const _ReasonRow({
required this.label,
required this.icon,
required this.accent,
required this.selected,
required this.showRule,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
if (showRule)
Divider(
height: 1,
thickness: 1,
indent: 33.w,
color: ColorConstants.borderSubtle,
),
Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 11.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
color: selected
? accent.withValues(alpha: 0.10)
: Colors.transparent,
),
child: Row(
children: [
Icon(
icon,
size: 19.sp,
color: selected ? accent : ColorConstants.secondaryText,
),
SizedBox(width: 13.w),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 14.5.sp,
color: selected ? accent : ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
fontWeight: selected
? FontWeight.w800
: FontWeight.w600,
),
),
),
// The tick appears rather than an empty ring sitting on every
// row: hollow circles read as more things to deal with, when
// only one of them is ever the answer.
AnimatedScale(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutBack,
scale: selected ? 1 : 0.6,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 180),
opacity: selected ? 1 : 0,
child: Container(
width: 22.w,
height: 22.w,
decoration: BoxDecoration(
color: accent,
shape: BoxShape.circle,
),
child: Icon(
Icons.check_rounded,
color: Colors.white,
size: 14.sp,
),
),
),
),
],
),
),
),
),
],
);
}
}
// `_ReasonRow` was promoted to the sheet kit as [MilerSheetChoiceRow]
// the day the Reject sheet became its third caller.

View File

@@ -0,0 +1,904 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:geolocator/geolocator.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
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';
/// ─────────────────────────────────────────────────────────────────────────
/// THE SHIPMENT DESK — what a logistics rider settles at the customer's door.
///
/// A logistics booking usually arrives half-known. The customer raised it from
/// a map pin and described the destination as a phone number and a landmark;
/// nobody has weighed anything. The rider standing in the sender's doorway is
/// the first person who can establish any of it, and everything downstream —
/// which hub it routes through, what it costs, what the customer pays now —
/// follows from what he writes here.
///
/// ```
/// FROM ──▶ TO ──▶ WEIGHT ──▶ DISTANCE ──▶ PRICE ──▶ (payment, then initiate)
/// ```
///
/// ── Why the four steps are on one screen ──
///
/// They are not four decisions; they are one conversation, held once, standing
/// up. A wizard would make the rider commit to an address before he has been
/// told the weight, and re-pricing after a correction would mean walking back
/// through pages. Here every field feeds the same quote, and the quote
/// re-computes when any of them changes.
///
/// ── What this screen does NOT do ──
///
/// It does not take money and it does not initiate anything. It hands its
/// capture back to the caller, which sends the addresses, then the parcel
/// measurements, then the payment, and only then `pickup-complete`. That order
/// is the contract's, not a preference: `pickup-complete` converts the booking
/// into a consignment and reads the addresses to route it, so anything written
/// after it has nowhere to land.
///
/// ── And it never invents a fare ──
///
/// The price comes from `POST /pricing/check` against the real pincodes and the
/// real weight. When the table has no rule for a combination the screen says so
/// and refuses to continue, because the alternative — a locally computed number
/// that looks official on a doorstep — is how a rider ends up collecting the
/// wrong money in the company's name.
/// ─────────────────────────────────────────────────────────────────────────
class ShipmentCapturePage extends StatefulWidget {
final Map<String, dynamic> pickup;
const ShipmentCapturePage({super.key, required this.pickup});
@override
State<ShipmentCapturePage> createState() => _ShipmentCapturePageState();
}
class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
// ── FROM ──
final _fromAddress = TextEditingController();
final _fromPincode = TextEditingController();
// ── TO ──
final _toAddress = TextEditingController();
final _toPincode = TextEditingController();
final _toCity = TextEditingController();
// ── The shipment ──
final _weight = TextEditingController();
String _serviceType = 'Normal';
String _category = 'General';
/// The rider's fix at the door, so the FROM pin is where he is standing
/// rather than where the customer dropped a marker last week.
double? _fromLat;
double? _fromLon;
ShipmentQuote? _quote;
bool _quoting = false;
String? _quoteError;
/// Debounces the quote: the weight field re-fires on every keystroke and
/// "1", "1.", "1.2" are three requests for one number.
Timer? _quoteDebounce;
@override
void initState() {
super.initState();
final d = widget.pickup;
// Seeded from the booking so the rider is confirming, not transcribing.
// Most of the time the customer's address is right and he changes nothing.
_fromAddress.text = (d['pickupaddress'] ?? d['PickupAddress'] ?? '')
.toString();
_fromPincode.text = (d['pickuppincode'] ?? '').toString();
_toAddress.text = (d['dropaddress'] ?? d['DropAddress'] ?? '').toString();
_toPincode.text = (d['droppincode'] ?? '').toString();
_fromLat = double.tryParse('${d['pickuplat'] ?? ''}');
_fromLon = double.tryParse('${d['pickuplon'] ?? ''}');
_captureRiderPosition();
for (final c in [_fromPincode, _toPincode, _weight]) {
c.addListener(_scheduleQuote);
}
}
@override
void dispose() {
_quoteDebounce?.cancel();
for (final c in [
_fromAddress,
_fromPincode,
_toAddress,
_toPincode,
_toCity,
_weight,
]) {
c.dispose();
}
super.dispose();
}
/// The FROM pin, taken where the rider is standing.
///
/// Best-effort: a failed fix leaves the booking's own coordinates in place,
/// which is what the app had before. Never blocks the form.
Future<void> _captureRiderPosition() async {
try {
final pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
timeLimit: Duration(seconds: 6),
),
).timeout(const Duration(seconds: 7));
if (!mounted) return;
setState(() {
_fromLat = pos.latitude;
_fromLon = pos.longitude;
});
} catch (_) {
// Keep whatever the booking carried.
}
}
double get _weightKg => double.tryParse(_weight.text.trim()) ?? 0;
/// Pincodes are what the server derives the zone from, so a quote is
/// impossible without both — and a six-digit one is the only shape that can
/// be one.
bool get _canQuote =>
_weightKg > 0 &&
_fromPincode.text.trim().length == 6 &&
_toPincode.text.trim().length == 6;
void _scheduleQuote() {
_quoteDebounce?.cancel();
if (!_canQuote) {
if (_quote != null || _quoteError != null) {
setState(() {
_quote = null;
_quoteError = null;
});
}
return;
}
_quoteDebounce = Timer(const Duration(milliseconds: 600), _fetchQuote);
}
Future<void> _fetchQuote() async {
if (!_canQuote || _quoting) return;
setState(() {
_quoting = true;
_quoteError = null;
});
final res = await MilerApi.checkPrice(
weight: _weightKg,
serviceType: _serviceType,
pickupPincode: _fromPincode.text.trim(),
deliveryPincode: _toPincode.text.trim(),
category: _category,
);
if (!mounted) return;
if (!res.ok) {
setState(() {
_quoting = false;
_quote = null;
// The network failed, which is a "try again" — distinct from the table
// having no rule, which is not.
_quoteError = res.message.isEmpty
? 'Could not reach pricing. Check your connection and retry.'
: res.message;
});
return;
}
final quote = ShipmentQuote.fromData(res.map, preferredCategory: _category);
setState(() {
_quoting = false;
_quote = quote.found ? quote : null;
_quoteError = quote.found
? null
: 'No price is configured for this weight and route. '
'Call the hub before collecting.';
});
}
/// ── The straight-line distance between the two ends ──
///
/// Shown, not charged. The fare comes from the pricing table's zone, which
/// the server derives from the pincodes; this is the sanity check that tells
/// a rider he has typed a destination pincode from the wrong city before he
/// takes money for it. Labelled as approximate for exactly that reason —
/// claiming a road distance the app has not measured would be a worse lie
/// than showing none.
double? get _approxKm {
final toLat = double.tryParse('${widget.pickup['droplat'] ?? ''}');
final toLon = double.tryParse('${widget.pickup['droplon'] ?? ''}');
if (_fromLat == null || _fromLon == null) return null;
if (toLat == null || toLon == null || toLat == 0 || toLon == 0) return null;
return Geolocator.distanceBetween(_fromLat!, _fromLon!, toLat, toLon) /
1000.0;
}
bool get _isComplete =>
_fromAddress.text.trim().isNotEmpty &&
_fromPincode.text.trim().length == 6 &&
_toAddress.text.trim().isNotEmpty &&
_toPincode.text.trim().length == 6 &&
_weightKg > 0 &&
_quote != null;
/// What is still missing, named. A disabled button with no reason is the
/// thing riders phone the hub about.
String get _blockingReason {
if (_fromAddress.text.trim().isEmpty) return 'Confirm the pickup address';
if (_fromPincode.text.trim().length != 6) return 'Pickup pincode needed';
if (_toAddress.text.trim().isEmpty) return 'Enter the delivery address';
if (_toPincode.text.trim().length != 6) return 'Delivery pincode needed';
if (_weightKg <= 0) return 'Weigh the parcel';
if (_quoting) return 'Getting the price…';
if (_quoteError != null) return _quoteError!;
return 'Waiting for the price';
}
void _submit() {
if (!_isComplete) {
AppFeedback.info(context, _blockingReason);
return;
}
final q = _quote!;
Navigator.pop(context, <String, dynamic>{
'captured': true,
'from': {
'address': _fromAddress.text.trim(),
'pincode': _fromPincode.text.trim(),
'lat': _fromLat,
'lon': _fromLon,
},
'to': {
'address': _toAddress.text.trim(),
'pincode': _toPincode.text.trim(),
'city': _toCity.text.trim(),
'lat': widget.pickup['droplat'],
'lon': widget.pickup['droplon'],
},
'weight': _weightKg,
'category': _category,
'servicetype': _serviceType,
'zone': q.zone,
// The figure the customer is about to be asked for. The payment screen
// takes it from here so the two can never disagree about the amount.
'price': q.payable,
'pricemin': q.minPrice,
'pricemax': q.maxPrice,
'currency': q.currency,
'approxkm': _approxKm,
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorConstants.daylightSurface,
appBar: milerPageBar('Shipment details'),
body: SafeArea(
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_journeyCard(),
SizedBox(height: 14.h),
_parcelCard(),
SizedBox(height: 14.h),
_quotePanel(),
],
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 12.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!_isComplete)
Padding(
padding: EdgeInsets.only(bottom: 8.h),
child: Text(
_blockingReason,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
PrimaryButton(
label: _quote == null
? 'Continue to payment'
: 'Continue · ${_quote!.currency} '
'${_quote!.payable.toStringAsFixed(0)}',
onPressed: _isComplete ? _submit : null,
),
],
),
),
],
),
),
);
}
/// ─────────────────────────────────────────────────────────────────────
/// THE JOURNEY, AS ONE OBJECT
///
/// From and To were two headed groups of loose white fields on a grey page —
/// address, pincode, address, pincode, city — five pills with nothing
/// relating them. The rider reading it back to a customer had to hold the
/// pairing in his head, and the one fact the screen is actually about, that
/// this parcel goes from *here* to *there*, was never drawn.
///
/// So it is drawn: one surface, one rail, a dot at the origin and a pin at
/// the destination, with each end's fields grouped under its own marker.
/// This is the shape every logistics app in the world uses for a journey,
/// 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),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_leg(
marker: _RouteMarker.origin,
label: 'FROM',
children: [
_field(
controller: _fromAddress,
hint: 'Pickup address',
icon: LucideIcons.house,
maxLines: 2,
),
SizedBox(height: 8.h),
_field(
controller: _fromPincode,
hint: 'Pincode',
icon: LucideIcons.mailbox,
keyboardType: TextInputType.number,
maxLength: 6,
digitsOnly: true,
),
],
),
_leg(
marker: _RouteMarker.destination,
label: 'TO',
children: [
_field(
controller: _toAddress,
hint: 'Delivery address',
icon: LucideIcons.mapPin,
maxLines: 2,
),
SizedBox(height: 8.h),
Row(
children: [
Expanded(
child: _field(
controller: _toPincode,
hint: 'Pincode',
icon: LucideIcons.mailbox,
keyboardType: TextInputType.number,
maxLength: 6,
digitsOnly: true,
),
),
SizedBox(width: 8.w),
Expanded(
child: _field(
controller: _toCity,
hint: 'City',
icon: LucideIcons.building2,
),
),
],
),
],
),
],
),
);
}
/// One end of the journey: its marker, its name, its fields.
///
/// The rail is drawn by the marker column rather than behind the row, so it
/// stretches to whatever height the fields take — an address that wraps to
/// two lines lengthens the line instead of breaking it.
Widget _leg({
required _RouteMarker marker,
required String label,
required List<Widget> children,
}) {
final origin = marker == _RouteMarker.origin;
final tint = origin
? ColorConstants.pickupAccent
: ColorConstants.deliveryAccent;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 26.w,
child: Column(
children: [
SizedBox(height: 3.h),
origin
? Container(
width: 11.w,
height: 11.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: tint, width: 3),
),
)
: Icon(LucideIcons.mapPin, size: 15.sp, color: tint),
if (origin)
Expanded(
child: Container(
width: 2,
margin: EdgeInsets.symmetric(vertical: 4.h),
color: ColorConstants.borderStrong,
),
),
],
),
),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: origin ? 14.h : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
color: tint,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 8.h),
...children,
],
),
),
),
],
),
);
}
/// What is being sent — weight, what it is, how fast.
///
/// 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.
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),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
LucideIcons.package,
size: 15.sp,
color: ColorConstants.secondaryText,
),
SizedBox(width: 7.w),
Text(
'THE PARCEL',
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
SizedBox(height: 12.h),
_field(
controller: _weight,
hint: 'Weight (kg)',
icon: LucideIcons.weight,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
SizedBox(height: 12.h),
_chips(
label: 'Category',
values: MilerApi.parcelCategories,
selected: _category,
onSelect: (v) {
setState(() => _category = v);
_scheduleQuote();
},
),
SizedBox(height: 12.h),
_chips(
label: 'Service',
values: MilerApi.serviceTypes,
selected: _serviceType,
onSelect: (v) {
setState(() => _serviceType = v);
_scheduleQuote();
},
),
],
),
);
}
/// The number the rider says out loud, at the size he can read it at.
///
/// ── Why the total is not a table row ──
///
/// This was four `label · value` rows of equal weight — distance, zone,
/// chargeable weight, price — with the price differing only by a bold. But
/// three of those exist to *explain* the fourth, and the fourth is the only
/// one that gets spoken to a customer. Read at arm's length on a doorstep,
/// a bolded 13sp among three 13sp rows is not a total.
///
/// So the money leads at display size, on the accent, and the workings sit
/// under it as the supporting detail they are.
Widget _quotePanel() {
final km = _approxKm;
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,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
LucideIcons.receiptText,
size: 15.sp,
color: ColorConstants.secondaryText,
),
SizedBox(width: 7.w),
Text(
'WHAT THE CUSTOMER PAYS',
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
SizedBox(height: 12.h),
if (priced) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
quote.currency,
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.acceptGreen,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(width: 3.w),
Flexible(
child: Text(
quote.payable.toStringAsFixed(0),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 34.sp,
height: 1.05,
fontWeight: FontWeight.w800,
letterSpacing: -1.2,
color: ColorConstants.acceptGreen,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
if (quote.isRange) ...[
SizedBox(height: 2.h),
Text(
'Band ${quote.minPrice.toStringAsFixed(0)}–'
'${quote.maxPrice.toStringAsFixed(0)} · charging the lower',
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
SizedBox(height: 12.h),
Container(height: 1, color: ColorConstants.borderSubtle),
SizedBox(height: 10.h),
_row('Zone', quote.zone),
SizedBox(height: 6.h),
_row('Chargeable weight', '${quote.weight} kg'),
SizedBox(height: 6.h),
],
_row(
'Distance',
km == null ? 'Not available' : '≈ ${km.toStringAsFixed(1)} km',
// Said out loud rather than left for the rider to assume: it is a
// straight line between two pins, not a road distance, and the
// fare does not come from it.
note: km == null ? null : 'straight line — the fare uses the zone',
),
if (!priced) SizedBox(height: 12.h),
if (_quoting)
Row(
children: [
SizedBox(
width: 14.w,
height: 14.w,
child: const CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 10.w),
Text(
'Getting the price…',
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
)
else if (_quoteError != null)
InfoBanner(
icon: LucideIcons.circleAlert,
text: _quoteError!,
color: ColorConstants.errorRed,
)
else if (_quote == null)
Text(
'Enter both pincodes and the weight to get a price.',
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
Widget _row(
String label,
String value, {
bool emphasis = false,
String? note,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
Text(
value,
style: TextStyle(
fontSize: emphasis ? 17.sp : 13.sp,
fontWeight: emphasis ? FontWeight.w800 : FontWeight.w600,
color: emphasis
? ColorConstants.primary
: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
if (note != null)
Padding(
padding: EdgeInsets.only(top: 2.h),
child: Text(
note,
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
);
}
Widget _field({
required TextEditingController controller,
required String hint,
required IconData icon,
TextInputType? keyboardType,
int maxLines = 1,
int? maxLength,
bool digitsOnly = false,
}) {
return TextField(
controller: controller,
keyboardType: keyboardType,
maxLines: maxLines,
maxLength: maxLength,
inputFormatters: digitsOnly
? [FilteringTextInputFormatter.digitsOnly]
: null,
onChanged: (_) => setState(() {}),
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
decoration: InputDecoration(
counterText: '',
hintText: hint,
prefixIcon: Icon(icon, size: 18.sp),
filled: true,
fillColor: ColorConstants.pureSurface,
hintStyle: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
contentPadding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
borderSide: BorderSide(color: ColorConstants.borderSubtle),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
borderSide: BorderSide(color: ColorConstants.borderSubtle),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
borderSide: BorderSide(color: ColorConstants.primary, width: 1.4),
),
),
);
}
Widget _chips({
required String label,
required List<String> values,
required String selected,
required ValueChanged<String> onSelect,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 6.h),
Wrap(
spacing: 6.w,
runSpacing: 6.h,
children: [
for (final v in values)
GestureDetector(
onTap: () => onSelect(v),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 7.h,
),
decoration: BoxDecoration(
color: v == selected
? ColorConstants.primary
: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(
DesignConstants.radiusFull,
),
border: Border.all(
color: v == selected
? ColorConstants.primary
: ColorConstants.borderSubtle,
),
),
child: Text(
v,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: v == selected
? ColorConstants.onAccent
: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
),
],
),
],
);
}
}
/// Which end of a journey a marker draws.
enum _RouteMarker { origin, destination }

View File

@@ -0,0 +1,702 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
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';
/// ─────────────────────────────────────────────────────────────────────────
/// REVIEW & PAY — the last screen before a shipment exists.
///
/// ```
/// verify parcel ──▶ shipment desk ──▶ [ REVIEW & PAY ] ──▶ order created
/// everything on
/// one screen
/// ```
///
/// ── Why this replaced a review, a payment page and a confirm sheet ──
///
/// The rider used to close a logistics stop across four screens: the verify
/// page's own review, then the shipment desk, then a confirm sheet, then a
/// payment page. Three of those asked him to look at the same facts again, and
/// the one that took the money was the furthest from the one that priced it —
/// so the number he quoted the customer and the number he collected were two
/// screens apart with no way to see them together.
///
/// This is the single review: what is being shipped, where from, where to, what
/// it weighs, what it costs, and how the customer is paying — then one button
/// that says the amount out loud. **`Pay ₹150`**, not `Confirm`. A rider taking
/// cash on a doorstep should never have to remember what the total was.
///
/// ── The button is the commit ──
///
/// Pressing it does three things in the order the contract requires: records
/// the payment, then posts `pickup-complete`, which converts the booking into a
/// live consignment with a tracking number. Only then does the screen change.
/// If any of it fails the rider stays here with the reason — nothing is
/// reported as created that was not created.
/// ─────────────────────────────────────────────────────────────────────────
/// How the customer paid. The wire values the payment endpoint expects.
enum ShipmentPayMethod { cash, upi }
extension ShipmentPayMethodX on ShipmentPayMethod {
String get label => switch (this) {
ShipmentPayMethod.cash => 'Cash',
ShipmentPayMethod.upi => 'UPI',
};
IconData get icon => switch (this) {
ShipmentPayMethod.cash => LucideIcons.banknote,
ShipmentPayMethod.upi => LucideIcons.qrCode,
};
/// Lower-case, matching what `UpdatePickupProvider.submitPayment` maps from.
String get wireName => name;
}
class ShipmentReviewPage extends StatefulWidget {
/// The stop being closed.
final Map<String, dynamic> pickup;
/// What the shipment desk captured — from, to, weight, zone, price.
final Map<String, dynamic> shipment;
/// Takes the money and creates the order. Returns null on success, or a
/// message to show the rider when it could not be completed.
///
/// Injected rather than called directly so this page stays a widget: the
/// caller owns the provider, the geofence and the controller.
final Future<String?> Function(ShipmentPayMethod method, double amount)
onConfirm;
/// Read once the order exists, for the receipt line.
final String Function() trackingNo;
const ShipmentReviewPage({
super.key,
required this.pickup,
required this.shipment,
required this.onConfirm,
required this.trackingNo,
});
@override
State<ShipmentReviewPage> createState() => _ShipmentReviewPageState();
}
class _ShipmentReviewPageState extends State<ShipmentReviewPage>
with TickerProviderStateMixin {
ShipmentPayMethod _method = ShipmentPayMethod.cash;
bool _busy = false;
bool _created = false;
String? _error;
// ── Built in initState, deliberately, not as `late final` ──
//
// A `late final` controller is only constructed on first read — and on a
// review the rider abandons, the first read is `dispose()`. Creating a Ticker
// there looks up `TickerMode` on an element that is already deactivated,
// which throws while the tree is being torn down. Eager construction is the
// only ordering that is correct for every exit from this screen.
late final AnimationController _successController;
late final Animation<double> _tickScale;
late final Animation<double> _ringScale;
late final Animation<double> _copyFade;
@override
void initState() {
super.initState();
_successController = AnimationController(
vsync: this,
duration: DesignConstants.motionCelebrate,
);
_tickScale = CurvedAnimation(
parent: _successController,
// Overshoots and settles — the shape of a stamp coming down, which is
// what this moment is. A linear tick reads as a loading state that
// stopped.
curve: Curves.elasticOut,
);
_ringScale = CurvedAnimation(
parent: _successController,
curve: const Interval(0, 0.55, curve: Curves.easeOutCubic),
);
_copyFade = CurvedAnimation(
parent: _successController,
curve: const Interval(0.35, 1, curve: Curves.easeOut),
);
}
@override
void dispose() {
_successController.dispose();
super.dispose();
}
double get _amount {
final v = widget.shipment['price'];
if (v is num) return v.toDouble();
return double.tryParse('${v ?? ''}') ?? 0;
}
String get _currency => (widget.shipment['currency'] ?? 'INR').toString();
/// `₹150` — the figure on the button and in the summary, from one place so
/// the two can never disagree.
String get _amountLabel {
final symbol = _currency == 'INR' ? '₹' : '$_currency ';
return '$symbol${_amount.toStringAsFixed(0)}';
}
String _s(dynamic v) => (v ?? '').toString().trim();
Map<String, dynamic> get _from =>
(widget.shipment['from'] as Map?)?.cast<String, dynamic>() ?? {};
Map<String, dynamic> get _to =>
(widget.shipment['to'] as Map?)?.cast<String, dynamic>() ?? {};
Future<void> _confirm() async {
if (_busy) return;
setState(() {
_busy = true;
_error = null;
});
final failure = await widget.onConfirm(_method, _amount);
if (!mounted) return;
if (failure != null) {
// Stayed here on purpose. The order does not exist, and a success
// animation over a failed create is the one thing this screen must never
// do — the rider would ride off believing a shipment had been raised.
setState(() {
_busy = false;
_error = failure;
});
return;
}
setState(() {
_busy = false;
_created = true;
});
_successController.forward();
}
@override
Widget build(BuildContext context) {
return PopScope(
// Once the order exists, going back would offer to create it twice.
canPop: !_created && !_busy,
child: Scaffold(
backgroundColor: ColorConstants.daylightSurface,
appBar: _created ? null : milerPageBar('Review & pay'),
body: SafeArea(child: _created ? _successView() : _reviewView()),
),
);
}
// ── Review ─────────────────────────────────────────────────────────────
Widget _reviewView() {
return Column(
children: [
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_routeCard(),
SizedBox(height: 14.h),
const SectionHeader(title: 'Shipment'),
SizedBox(height: 8.h),
_shipmentCard(),
SizedBox(height: 14.h),
const SectionHeader(title: 'Payment'),
SizedBox(height: 8.h),
_paymentCard(),
if (_error != null) ...[
SizedBox(height: 14.h),
InfoBanner(
icon: LucideIcons.circleAlert,
text: _error!,
color: ColorConstants.errorRed,
),
],
],
),
),
),
_payBar(),
],
);
}
/// From → to, drawn as a journey rather than two labelled fields.
Widget _routeCard() {
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_routeEnd(
icon: LucideIcons.circleDot,
color: ColorConstants.primary,
label: 'FROM',
address: _s(_from['address']),
pincode: _s(_from['pincode']),
),
// The connector. Reads as one journey, which two stacked address
// blocks do not.
Padding(
padding: EdgeInsets.only(left: 9.w),
child: SizedBox(
height: 22.h,
child: VerticalDivider(
width: 2.w,
thickness: 2,
color: ColorConstants.borderSubtle,
),
),
),
_routeEnd(
icon: LucideIcons.mapPin,
color: ColorConstants.acceptGreen,
label: 'TO',
address: _s(_to['address']),
pincode: _s(_to['pincode']),
city: _s(_to['city']),
),
],
),
);
}
Widget _routeEnd({
required IconData icon,
required Color color,
required String label,
required String address,
required String pincode,
String city = '',
}) {
final tail = [
if (city.isNotEmpty) city,
if (pincode.isNotEmpty) pincode,
].join(' · ');
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18.sp, color: color),
SizedBox(width: 10.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 2.h),
Text(
address.isEmpty ? 'Not captured' : address,
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w600,
height: 1.3,
color: address.isEmpty
? ColorConstants.secondaryText
: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
if (tail.isNotEmpty)
Padding(
padding: EdgeInsets.only(top: 2.h),
child: Text(
tail,
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
],
);
}
Widget _shipmentCard() {
final weight = widget.shipment['weight'];
final km = widget.shipment['approxkm'];
return AppCard(
child: Column(
children: [
_row('Weight', weight == null ? '—' : '$weight kg'),
_divider(),
_row('Category', _s(widget.shipment['category'])),
_divider(),
_row('Service', _s(widget.shipment['servicetype'])),
if (_s(widget.shipment['zone']).isNotEmpty) ...[
_divider(),
_row('Zone', _s(widget.shipment['zone'])),
],
if (km is num) ...[
_divider(),
_row(
'Distance',
'≈ ${km.toStringAsFixed(1)} km',
// Said plainly: it is a straight line between two pins, and the
// fare comes from the zone. Letting the rider assume otherwise is
// how a customer gets told the wrong basis for a charge.
note: 'straight line — the fare uses the zone',
),
],
],
),
);
}
Widget _paymentCard() {
return AppCard(
child: Column(
children: [
Row(
children: [
for (final m in ShipmentPayMethod.values) ...[
Expanded(child: _methodTile(m)),
if (m != ShipmentPayMethod.values.last) SizedBox(width: 8.w),
],
],
),
SizedBox(height: 14.h),
Row(
children: [
Expanded(
child: Text(
'Amount to collect',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
Text(
_amountLabel,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.4,
color: ColorConstants.primary,
fontFamily: FontConstants.fontFamily,
),
),
],
),
],
),
);
}
Widget _methodTile(ShipmentPayMethod m) {
final selected = _method == m;
return GestureDetector(
onTap: _busy ? null : () => setState(() => _method = m),
child: AnimatedContainer(
duration: DesignConstants.motionState,
padding: EdgeInsets.symmetric(vertical: 12.h),
decoration: BoxDecoration(
color: selected
? ColorConstants.primary.withValues(alpha: 0.08)
: ColorConstants.daylightSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
border: Border.all(
color: selected
? ColorConstants.primary
: ColorConstants.borderSubtle,
width: selected ? 1.6 : 1,
),
),
child: Column(
children: [
Icon(
m.icon,
size: 20.sp,
color: selected
? ColorConstants.primary
: ColorConstants.secondaryText,
),
SizedBox(height: 5.h),
Text(
m.label,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
color: selected
? ColorConstants.primary
: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
);
}
/// The commit. Names the amount, because that is the fact the rider is acting
/// on and the one he will be asked for at the door.
Widget _payBar() {
return Container(
padding: EdgeInsets.fromLTRB(16.w, 10.h, 16.w, 12.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
border: Border(
top: BorderSide(color: ColorConstants.borderSubtle, width: 1),
),
boxShadow: DesignConstants.shadowSm,
),
child: PrimaryButton(
label: _amount > 0
? 'Pay $_amountLabel'
// A prepaid or zero-rated shipment still has to be created; the
// button says what it does rather than asking for money nobody owes.
: 'Create order',
loading: _busy,
onPressed: _busy ? null : _confirm,
),
);
}
Widget _row(String label, String value, {String? note}) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 7.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
Text(
value.isEmpty ? '—' : value,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
if (note != null)
Padding(
padding: EdgeInsets.only(top: 2.h),
child: Text(
note,
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
);
}
Widget _divider() => Divider(height: 1.h, color: ColorConstants.borderSubtle);
// ── Success ────────────────────────────────────────────────────────────
//
// One moment, three beats, staggered so they read as a sequence rather than
// a screen that swapped: the ring opens, the tick stamps down inside it, and
// the words arrive last. All of it drives off one controller, so the timing
// cannot drift.
Widget _successView() {
final tracking = widget.trackingNo();
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Spacer(),
AnimatedBuilder(
animation: _successController,
builder: (_, _) => SizedBox(
width: 132.w,
height: 132.w,
child: Stack(
alignment: Alignment.center,
children: [
// The ring, which expands and fades — a single ripple, not a
// loop. A repeating pulse would keep asking for attention on
// a screen whose job is finished.
Transform.scale(
scale: 0.6 + (_ringScale.value * 0.55),
child: Opacity(
opacity: (1 - _ringScale.value).clamp(0.0, 1.0) * 0.35,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: ColorConstants.acceptGreen,
),
),
),
),
Transform.scale(
scale: _tickScale.value.clamp(0.0, 1.2),
child: Container(
width: 92.w,
height: 92.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: ColorConstants.acceptGreen,
boxShadow: [
BoxShadow(
color: ColorConstants.acceptGreen.withValues(
alpha: 0.28,
),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: Icon(
LucideIcons.check,
size: 46.sp,
color: ColorConstants.onAccent,
),
),
),
],
),
),
),
SizedBox(height: 26.h),
FadeTransition(
opacity: _copyFade,
child: Column(
children: [
Text(
'Your order has been created',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.4,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 8.h),
Text(
_amount > 0
? '$_amountLabel collected by ${_method.label}'
: 'Shipment created',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
// The receipt. Real data from `pickup-complete`, so a customer
// asking "what's my tracking number?" gets an answer on the
// doorstep instead of a phone call to the hub.
if (tracking.isNotEmpty) ...[
SizedBox(height: 18.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 10.h,
),
decoration: BoxDecoration(
color: ColorConstants.daylightSurface,
borderRadius: BorderRadius.circular(
DesignConstants.radiusFull,
),
border: Border.all(color: ColorConstants.borderSubtle),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.truck,
size: 15.sp,
color: ColorConstants.secondaryText,
),
SizedBox(width: 8.w),
Text(
tracking,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
],
],
),
),
const Spacer(),
FadeTransition(
opacity: _copyFade,
child: Padding(
padding: EdgeInsets.only(bottom: 16.h),
child: PrimaryButton(
label: 'Done',
onPressed: () => Navigator.of(context).pop(<String, dynamic>{
// The shape `map.dart` already reads, so the handoff screen,
// the compliance stamping and the next-stop list all keep
// working untouched.
'outcome': 'completed',
'isDelivery': false,
'paymentMethod': _method.label,
'amountCollected': _amount,
}),
),
),
),
],
),
);
}
}

View File

@@ -8,397 +8,360 @@ Future<void> _showMyOptionsSheet(
int selected = -1;
bool isLoading = false;
// ── Why the list is this long, and no longer ──
//
// Two reasons was not a list, it was a shrug: every skip that was not a
// missed call came back to the office as "customer not at the location",
// which is the one thing operations cannot act on. These are the eight
// outcomes a rider actually reports, and each one implies a different next
// move for somebody — reschedule, re-address, call the client, refund.
//
// Eight is also the ceiling. A reason list a rider has to *read* at a gate is
// a reason list he will pick the first item from.
final List<String> skipReasons = [
'Customer unreachable',
'Customer not at the location',
'Wrong address',
'Access problem',
'Customer refused',
'Delivery paused',
'Quantity issue',
'Other',
];
await showModalBottomSheet(
context: context,
sheetAnimationStyle: kMilerSheetStyle,
isScrollControlled: true,
backgroundColor: Colors.transparent,
// ── Through the kit ──
//
// Three violations in one sheet, and each is the kind that only shows up on
// somebody else's phone:
//
// • its own `SafeArea(bottom: true)` **wrapping** the sheet, on top of the
// 20 of bottom padding inside it — the double-inset rule exists for
// exactly this, and it is the one thing the system calls non-negotiable;
// • a hand-rolled scaffold — its own `milerGlassSheet`, its own gutter, its
// own handle — which is four decisions re-made and one of them (`12`
// top padding, not the kit's) already drifting;
// • a close **✕**, the one affordance the system forbids. The handle and
// the barrier already dismiss; a third way is a control that has to be
// reached for with a thumb that is holding the phone.
await showMilerSheet(
context,
builder: (context) {
return SafeArea(
top: false,
left: false,
right: false,
bottom: true,
child: StatefulBuilder(
builder: (context, setState) {
// The reason list is the same question Update Status asks, so it is
// the same widget — see [_ReasonRow]. It used to be two outlined
// white cards on a white sheet, each with its icon inside a second
// box, and the selected one swapped its border for a thicker
// coloured one.
Widget reasonRow(String title, int index, IconData icon) {
return _ReasonRow(
label: title,
icon: icon,
accent: ColorConstants.primary,
selected: selected == index,
// No rule either side of the filled row: a hairline across the
// tint turns the chosen answer back into a box.
showRule:
index > 0 && selected != index && selected != index - 1,
onTap: isLoading
? null
: () => setState(() => selected = index),
);
}
return StatefulBuilder(
builder: (context, setState) {
// The reason list is the same question Update Status asks, so it is
// the same widget — see [MilerSheetChoiceRow]. It used to be two outlined
// white cards on a white sheet, each with its icon inside a second
// box, and the selected one swapped its border for a thicker
// coloured one.
Widget reasonRow(String title, int index, IconData icon) {
return MilerSheetChoiceRow(
label: title,
icon: icon,
accent: ColorConstants.primary,
selected: selected == index,
// No rule either side of the filled row: a hairline across the
// tint turns the chosen answer back into a box.
showRule: index > 0 && selected != index && selected != index - 1,
onTap: isLoading ? null : () => setState(() => selected = index),
);
}
Future<void> handleConfirm() async {
if (selected == -1 || isLoading) return;
Future<void> handleConfirm() async {
if (selected == -1 || isLoading) return;
final dc = Get.put(PickupsController(), permanent: true);
final skipStatus = await dc.checkSkipStatus();
final int skipCount = skipStatus['count'] ?? 0;
bool applyPenalty = false;
final dc = Get.put(PickupsController(), permanent: true);
final skipStatus = await dc.checkSkipStatus();
final int skipCount = skipStatus['count'] ?? 0;
bool applyPenalty = false;
if (skipCount >= 2) {
final bool? confirm = await showAppDialog<bool>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
if (skipCount >= 2) {
final bool? confirm = await showAppDialog<bool>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
DesignConstants.radiusXl,
),
elevation: 0,
backgroundColor: ColorConstants.pureSurface,
child: Padding(
padding: EdgeInsets.all(24.r),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.all(16.r),
decoration: BoxDecoration(
color: ColorConstants.errorLight,
shape: BoxShape.circle,
),
child: Icon(
Icons.warning_amber_rounded,
color: ColorConstants.errorRed,
size: 36.sp,
),
elevation: 0,
backgroundColor: ColorConstants.pureSurface,
child: Padding(
padding: EdgeInsets.all(24.r),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.all(16.r),
decoration: BoxDecoration(
color: ColorConstants.errorLight,
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.triangleAlert,
color: ColorConstants.errorRed,
size: 36.sp,
),
),
SizedBox(height: 20.h),
Text(
'Skip Limit Reached',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.onSurface,
),
),
SizedBox(height: 10.h),
Text(
'You have exceeded the limit of 2 skips within 3 hours.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15.sp,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.onSurfaceVariant,
height: 1.4,
),
),
SizedBox(height: 14.h),
Container(
padding: EdgeInsets.all(12.r),
// Fill, no outline — the same rule the sheets
// now follow. A tinted panel is already a panel.
decoration: BoxDecoration(
color: ColorConstants.warningContainer,
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
),
SizedBox(height: 20.h),
Text(
'Skip Limit Reached',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.onSurface,
),
),
SizedBox(height: 10.h),
Text(
'You have exceeded the limit of 2 skips within 3 hours.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15.sp,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.onSurfaceVariant,
height: 1.4,
),
),
SizedBox(height: 14.h),
Container(
padding: EdgeInsets.all(12.r),
// Fill, no outline — the same rule the sheets
// now follow. A tinted panel is already a panel.
decoration: BoxDecoration(
color: ColorConstants.warningContainer,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Row(
children: [
Icon(
Icons.info_outline,
color: ColorConstants.warning,
size: 18.sp,
),
SizedBox(width: 8.w),
Expanded(
child: Text(
"Proceeding will forfeit your bonus points for this session.",
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.rewardAmber,
fontFamily: FontConstants.fontFamily,
height: 1.3,
),
),
),
],
),
),
SizedBox(height: 24.h),
Row(
child: Row(
children: [
Expanded(
child: MilerButton(
label: "Cancel",
onPressed: () =>
Navigator.pop(context, false),
variant: MilerButtonVariant.outlined,
color: ColorConstants.onSurfaceVariant,
height: ButtonSizes.secondary,
),
Icon(
LucideIcons.info,
color: ColorConstants.warning,
size: 18.sp,
),
SizedBox(width: 12.w),
SizedBox(width: 8.w),
Expanded(
child: MilerButton(
label: "Confirm skip",
onPressed: () =>
Navigator.pop(context, true),
color: ColorConstants.errorRed,
height: ButtonSizes.secondary,
child: Text(
"Proceeding will forfeit your bonus points for this session.",
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.rewardAmber,
fontFamily: FontConstants.fontFamily,
height: 1.3,
),
),
),
],
),
],
),
),
);
},
);
if (confirm != true) return;
applyPenalty = true;
}
setState(() => isLoading = true);
try {
final dc = Get.put(PickupsController(), permanent: true);
final d = pickup;
final int pickupId = int.tryParse('${d['pickupid'] ?? 0}') ?? 0;
final int orderHeaderId =
int.tryParse('${d['orderheaderid'] ?? 0}') ?? 0;
final String reason =
selected >= 0 && selected < skipReasons.length
? skipReasons[selected]
: '';
if (pickupId > 0 && orderHeaderId > 0) {
String riderLatStr = '0';
String riderLngStr = '0';
try {
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
timeLimit: const Duration(seconds: 5),
);
riderLatStr = position.latitude.toStringAsFixed(6);
riderLngStr = position.longitude.toStringAsFixed(6);
} catch (e) {
debugPrint('[SKIP] Error getting rider location: $e');
try {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null) {
riderLatStr = lastPos.latitude.toStringAsFixed(6);
riderLngStr = lastPos.longitude.toStringAsFixed(6);
}
} catch (_) {}
}
debugPrint(
'[SKIP] Updating status for pickupId=$pickupId orderHeaderId=$orderHeaderId reason="$reason"',
);
final ok = await dc.updateSkippedStatus(
pickupId: pickupId,
orderHeaderId: orderHeaderId,
ridersLat: riderLatStr,
ridersLng: riderLngStr,
notes: reason,
);
debugPrint('[SKIP] Status update result: $ok');
// Optimistic skip — reflect it locally and move on even if the
// API fails (e.g. mock/offline data), so skip never appears
// unresponsive and the rider can proceed to the next stop.
await dc.registerSkip(applyPenalty: applyPenalty);
// Recorded before the sheet closes, and regardless of which
// screen the skip was taken from: the map screen passes no
// `parentState`, so the branch below is the only thing that
// remembered a skip and it did so in memory, on one screen.
// Activity is where the rider now finds a skipped stop, and
// it reads this store. See [addSkippedBooking].
await addSkippedBooking(d, reason: reason);
if (context.mounted) {
Navigator.pop(context);
if (parentState != null) {
parentState.markOrderAsSkipped(d, reason);
} else {
debugPrint(
'[SKIP] Skipped from Nav/Map screen, sheet closed.',
);
}
}
} else {
debugPrint(
'[SKIP] Invalid pickupId: $pickupId or orderHeaderId: $orderHeaderId',
);
if (context.mounted) {
AppFeedback.error(context, 'Invalid pickup information');
}
}
} catch (e) {
debugPrint('[SKIP] Error updating skip status: $e');
if (context.mounted) {
AppFeedback.error(context, 'Something went wrong — try again');
}
} finally {
if (context.mounted) {
setState(() => isLoading = false);
}
}
}
// Frosted, like every other sheet in the app — see
// [milerGlassSheet].
return milerGlassSheet(
child: Padding(
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Container(
width: 40.w,
height: 4.h,
decoration: BoxDecoration(
color: ColorConstants.borderSubtle,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
),
),
SizedBox(height: 16.h),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
),
SizedBox(height: 24.h),
Row(
children: [
Text(
'Skip this pickup',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.slateText,
Expanded(
child: MilerButton(
label: "Cancel",
onPressed: () =>
Navigator.pop(context, false),
variant: MilerButtonVariant.outlined,
color: ColorConstants.onSurfaceVariant,
height: ButtonSizes.secondary,
),
),
SizedBox(height: 2.h),
Text(
'Tell us why so we can reassign it',
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.secondaryText,
SizedBox(width: 12.w),
Expanded(
child: MilerButton(
label: "Confirm skip",
onPressed: () => Navigator.pop(context, true),
color: ColorConstants.errorRed,
height: ButtonSizes.secondary,
),
),
],
),
),
SizedBox(width: 10.w),
InkWell(
onTap: isLoading
? null
: () => Navigator.pop(context),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
child: Container(
padding: EdgeInsets.all(8.r),
decoration: BoxDecoration(
color: ColorConstants.neutralLight,
shape: BoxShape.circle,
),
child: Icon(
Icons.close_rounded,
color: ColorConstants.slateText,
size: 18.sp,
),
),
),
],
),
SizedBox(height: 16.h),
// ── Which pickup ──
//
// The sheet asked the rider to skip "this pickup" and never
// said which one. He arrives here from a swipe on a list of
// near-identical rows, and skipping the wrong stop is not an
// action he can take back — it goes to the office as a
// refusal against a customer who was standing there waiting.
_StopBrief(pickup: pickup, showRule: true),
SizedBox(height: 8.h),
reasonRow(
'Customer unreachable',
0,
Icons.phone_missed_rounded,
),
reasonRow(
'Customer not at the location',
1,
Icons.person_off_outlined,
),
SizedBox(height: 20.h),
if (isLoading)
Container(
height: ButtonSizes.primary,
alignment: Alignment.center,
decoration: BoxDecoration(
// Matches the button it stands in for.
color: ColorConstants.errorRed,
borderRadius: BorderRadius.circular(
ButtonSizes.radius,
),
),
child: const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
),
)
else
// ── Skipping is destructive, and looks it ──
//
// This was `primary` while the penalty confirmation
// nested inside the same flow was `errorRed` — one action
// in two colours, two taps apart. Skipping tells the hub
// a customer was not served; it is the destructive branch
// of this screen and takes the destructive colour, the
// same one Reject uses on Home.
MilerButton(
label: 'Confirm skip',
icon: Icons.skip_next_rounded,
color: ColorConstants.errorRed,
onPressed: selected == -1 ? null : handleConfirm,
],
),
],
),
),
);
},
);
if (confirm != true) return;
applyPenalty = true;
}
setState(() => isLoading = true);
try {
final dc = Get.put(PickupsController(), permanent: true);
final d = pickup;
final int pickupId = int.tryParse('${d['pickupid'] ?? 0}') ?? 0;
final int orderHeaderId =
int.tryParse('${d['orderheaderid'] ?? 0}') ?? 0;
final String reason =
selected >= 0 && selected < skipReasons.length
? skipReasons[selected]
: '';
if (pickupId > 0 && orderHeaderId > 0) {
String riderLatStr = '0';
String riderLngStr = '0';
try {
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
timeLimit: const Duration(seconds: 5),
);
riderLatStr = position.latitude.toStringAsFixed(6);
riderLngStr = position.longitude.toStringAsFixed(6);
} catch (e) {
debugPrint('[SKIP] Error getting rider location: $e');
try {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null) {
riderLatStr = lastPos.latitude.toStringAsFixed(6);
riderLngStr = lastPos.longitude.toStringAsFixed(6);
}
} catch (_) {}
}
debugPrint(
'[SKIP] Updating status for pickupId=$pickupId orderHeaderId=$orderHeaderId reason="$reason"',
);
final ok = await dc.updateSkippedStatus(
pickupId: pickupId,
orderHeaderId: orderHeaderId,
ridersLat: riderLatStr,
ridersLng: riderLngStr,
notes: reason,
);
debugPrint('[SKIP] Status update result: $ok');
// Optimistic skip — reflect it locally and move on even if the
// API fails (e.g. mock/offline data), so skip never appears
// unresponsive and the rider can proceed to the next stop.
await dc.registerSkip(applyPenalty: applyPenalty);
// Recorded before the sheet closes, and regardless of which
// screen the skip was taken from: the map screen passes no
// `parentState`, so the branch below is the only thing that
// remembered a skip and it did so in memory, on one screen.
// Activity is where the rider now finds a skipped stop, and
// it reads this store. See [addSkippedBooking].
await addSkippedBooking(d, reason: reason);
if (context.mounted) {
Navigator.pop(context);
if (parentState != null) {
parentState.markOrderAsSkipped(d, reason);
} else {
debugPrint(
'[SKIP] Skipped from Nav/Map screen, sheet closed.',
);
}
}
} else {
debugPrint(
'[SKIP] Invalid pickupId: $pickupId or orderHeaderId: $orderHeaderId',
);
if (context.mounted) {
AppFeedback.error(context, 'Invalid pickup information');
}
}
} catch (e) {
debugPrint('[SKIP] Error updating skip status: $e');
if (context.mounted) {
AppFeedback.error(context, 'Something went wrong — try again');
}
} finally {
if (context.mounted) {
setState(() => isLoading = false);
}
}
}
return MilerSheetScaffold(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// One header idiom. 18sp/w700 with a 13.5 subtitle, the
// same as every other sheet — this was 19/12.5 with its own
// colours, which is close enough to look like a mistake
// rather than a variant.
const MilerSheetHeader(
title: 'Skip this pickup',
subtitle: 'Tell us why, so the hub can reassign it.',
),
SizedBox(height: 16.h),
// ── Which pickup ──
//
// The sheet asked the rider to skip "this pickup" and never
// said which one. He arrives here from a swipe on a list of
// near-identical rows, and skipping the wrong stop is not an
// action he can take back — it goes to the office as a
// refusal against a customer who was standing there waiting.
_StopBrief(pickup: pickup, showRule: true),
SizedBox(height: 8.h),
reasonRow('Customer unreachable', 0, LucideIcons.phoneMissed),
reasonRow(
'Customer not at the location',
1,
LucideIcons.userX,
),
reasonRow('Wrong address', 2, LucideIcons.mapPinOff),
reasonRow('Access problem', 3, LucideIcons.lock),
reasonRow('Customer refused', 4, LucideIcons.ban),
reasonRow('Delivery paused', 5, LucideIcons.circlePause),
reasonRow('Quantity issue', 6, LucideIcons.package),
reasonRow('Other', 7, LucideIcons.ellipsis),
SizedBox(height: 20.h),
if (isLoading)
Container(
height: ButtonSizes.primary,
alignment: Alignment.center,
decoration: BoxDecoration(
// Matches the button it stands in for.
color: ColorConstants.errorRed,
borderRadius: BorderRadius.circular(ButtonSizes.radius),
),
child: const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
),
)
else
// ── Skipping is destructive, and looks it ──
//
// This was `primary` while the penalty confirmation
// nested inside the same flow was `errorRed` — one action
// in two colours, two taps apart. Skipping tells the hub
// a customer was not served; it is the destructive branch
// of this screen and takes the destructive colour, the
// same one Reject uses on Home.
MilerButton(
label: 'Confirm skip',
icon: LucideIcons.clock,
color: ColorConstants.errorRed,
onPressed: selected == -1 ? null : handleConfirm,
),
],
),
);
},
),
),
);
},
);
},
);

View File

@@ -1,5 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:miler/data/milk_run.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
/// Mixed pickup + delivery routes (Amazon/Flipkart-style), including stops
@@ -18,28 +20,35 @@ import 'package:miler/views/helpers/constants/Colorconstants.dart';
/// BACKEND DEPENDENCY: the app does not yet receive a stop `type` field. When
/// the backend starts sending `type`/`stoptype` = `pickup` | `delivery` |
/// `combined` (or sends both a delivery and a pickup quantity on one stop),
/// this helper picks it up automatically and the demo fallback is ignored.
/// this helper picks it up automatically. Until then every stop is a pickup.
enum StopKind { pickup, delivery, combined }
/// One unit of work inside a stop. A combined stop runs both, in order.
enum StopLeg { delivery, pickup }
/// Demo/mock switch. While the backend has no stop-type field, this makes
/// stop 2 a delivery and stop 3 a combined stop so all three card types and
/// flows can be exercised on real data.
///
/// Tied to [kDebugMode] so it is ALWAYS `false` in release builds. Once the
/// backend sends `type`, [stopKindOf] prefers it anyway and this is ignored.
const bool kDemoMixedRoute = kDebugMode;
/// Resolve a stop's kind.
///
/// Resolution order:
/// 1. An explicit backend `type` / `stoptype` / `stopType` string.
/// 2. Structural inference — a stop carrying BOTH a delivery quantity and a
/// pickup quantity is a combined stop even if never labelled one.
/// 3. The demo pattern, in debug builds only.
/// 3. The line of work — every customer stop on a meal run is a drop.
/// 4. Pickup — the app's original behaviour.
///
/// There used to be another rule: a debug-only pattern that made every even
/// stop a delivery and every third one combined, so the three card types could
/// be seen without backend support. It is gone. A rule that invents what a stop
/// *is* means a debug build shows a different route from the release build
/// standing next to it, and the first question on any bug report — "is that
/// what the server actually said?" — could not be answered from the screen.
///
/// Rule 3 is not that rule coming back. It does not invent anything: on a meal
/// run the *shape of the day* is the fact — the rider loads at a kitchen and
/// every stop after that is a subscriber's door. Falling through to "pickup"
/// there put the word **Picked up** on the confirm button at a customer's flat
/// and called his lunch a collection, for no better reason than that the
/// payload had not labelled a field. An explicit label still wins, so a meal
/// route that ever does carry a genuine collection is still read correctly.
StopKind stopKindOf(Map<String, dynamic> item) {
final raw = _rawType(item);
@@ -62,18 +71,10 @@ StopKind stopKindOf(Map<String, dynamic> item) {
return StopKind.combined;
}
if (kDemoMixedRoute) {
final stepRaw = item['step'] ?? item['Step'];
final step = stepRaw is num
? stepRaw.toInt()
: int.tryParse('${stepRaw ?? ''}') ?? 0;
// Stop 3, 6, 9… → combined; other even stops → delivery. A demo route
// therefore shows all three card types.
if (step > 0) {
if (step % 3 == 0) return StopKind.combined;
if (step.isEven) return StopKind.delivery;
}
}
// The kitchen is a source, not a stop. Everything in a meal rider's route
// list is somewhere he is handing food over.
if (ServiceProfile.active.sourceIsKitchen) return StopKind.delivery;
return StopKind.pickup;
}
@@ -122,7 +123,19 @@ int pickupParcelCount(Map<String, dynamic> item) {
/// Cash still to be collected at this stop, in rupees. Deliveries are prepaid,
/// so this only ever applies to a pickup leg.
///
/// ── The one gate for money, app-wide ──
///
/// A tenant whose customers pay by monthly subscription never collects at the
/// door, and this is where that is enforced rather than at each screen. Every
/// place that asks about money — the confirm sheet's `_hasCollection`, the
/// payment page's launch condition on Home, the amount printed on a stop card —
/// already routes through here and already handles zero, so one `if` retires
/// the whole cash flow for a service tenant instead of a branch per screen.
/// See [ServiceProfile.collectsCash].
double stopCollectionAmount(Map<String, dynamic> item) {
if (!ServiceProfile.active.collectsCash) return 0;
final v = item['collectionamt'] ?? item['pickupamt'] ?? item['codamount'];
if (v == null) return 0;
if (v is num) return v.toDouble();
@@ -130,6 +143,46 @@ double stopCollectionAmount(Map<String, dynamic> item) {
return double.tryParse(cleaned) ?? 0;
}
/// Where this stop's parcel came from — the kitchen, on a meal run.
///
/// Empty on a Doormile stop, which has no source worth naming: everything comes
/// from the hub the rider started at. On a service route it is the one fact
/// that tells two identical-looking bags apart when he is carrying loads from
/// two kitchens at once, so it is shown on the card rather than in a detail
/// sheet he will not open at a doorstep.
/// Delegates to [MilkRun.sourceNameOf] — the one reader.
///
/// This used to carry its own key list, and the two drifted: a payload with
/// only the CamelCase key had a source here and none there, so the route card
/// grouped a stop under a counter it then could not name and titled the group
/// "pickup". See [MilkRun.sourceNameOf].
String stopSourceName(Map<String, dynamic> item) => MilkRun.sourceNameOf(item);
/// Stable id for [stopSourceName], used to group and to gate.
///
/// The name is what the rider reads; this is what the app compares. Two
/// kitchens can share a display name across areas, and P3's rule — a drop stays
/// locked until *its* kitchen is collected — has to key off something the hub
/// controls.
String stopSourceId(Map<String, dynamic> item) =>
(item['kitchenid'] ?? item['KitchenId'] ?? item['sourceid'] ?? '')
.toString()
.trim();
/// The label physically on the bag, matched at the kitchen and read at the door.
///
/// Ten near-identical lunch bags is where mis-delivery comes from, and no OTP or
/// photo catches it — the rider is confident, he is just at the wrong door with
/// the wrong bag. A printed label on the card is the cheapest thing that does.
String stopBagLabel(Map<String, dynamic> item) =>
(item['baglabel'] ??
item['BagLabel'] ??
item['bagid'] ??
item['labelcode'] ??
'')
.toString()
.trim();
/// The delivery OTP, when the stop has a delivery leg.
String stopOtp(Map<String, dynamic> item) =>
(item['otp'] ?? item['Otp'] ?? item['deliveryotp'] ?? '').toString().trim();
@@ -181,24 +234,35 @@ extension StopKindUi on StopKind {
/// A darkened [accent] — see the note on [ColorConstants.pickupChip]. Use this
/// for the tag and nothing else: the chip is the one place stop type is
/// allowed to be a colour, because it is a mark rather than a surface.
Color get chipColor => switch (this) {
StopKind.pickup => ColorConstants.pickupChip,
StopKind.delivery => ColorConstants.deliveryChip,
StopKind.combined => ColorConstants.combinedChip,
};
///
/// One colour for the whole day on a service tenant, for the same reason its
/// label is one word — see [chipLabel].
Color get chipColor => ServiceProfile.active.usesSingleWorkType
? ColorConstants.serviceChip
: switch (this) {
StopKind.pickup => ColorConstants.pickupChip,
StopKind.delivery => ColorConstants.deliveryChip,
StopKind.combined => ColorConstants.combinedChip,
};
IconData get icon => switch (this) {
StopKind.pickup => Icons.inventory_2_rounded,
StopKind.delivery => Icons.local_shipping_rounded,
StopKind.combined => Icons.swap_horiz_rounded,
};
IconData get icon => ServiceProfile.active.usesSingleWorkType
// A meal, not a parcel or a lorry: what a stop on this line has in
// common across the day is that somebody is being handed food.
? LucideIcons.utensils
: switch (this) {
StopKind.pickup => LucideIcons.package,
StopKind.delivery => LucideIcons.truck,
StopKind.combined => LucideIcons.arrowLeftRight,
};
/// Short badge label.
String get badgeLabel => switch (this) {
StopKind.pickup => 'PICKUP',
StopKind.delivery => 'DELIVERY',
StopKind.combined => 'PICKUP & DELIVERY',
};
String get badgeLabel =>
ServiceProfile.active.workTypeLabel ??
switch (this) {
StopKind.pickup => 'PICKUP',
StopKind.delivery => 'DELIVERY',
StopKind.combined => 'PICKUP & DELIVERY',
};
/// Sentence-case name of the mode, for places that state it as a *label*
/// rather than stamp it as a chip — see the handoff screen's next-stop block.
@@ -207,11 +271,19 @@ extension StopKindUi on StopKind {
/// of other things. When the type is a quiet line under a hero heading there
/// is nothing to compete with, and all-caps at that size reads as a second
/// heading rather than as an attribute.
String get titleLabel => switch (this) {
StopKind.pickup => 'Pickup',
StopKind.delivery => 'Delivery',
StopKind.combined => 'Pickup & Delivery',
};
String get titleLabel {
final one = ServiceProfile.active.workTypeLabel;
if (one != null) {
// Sentence case, for the same reason the parcel labels are: all-caps at
// this size reads as a second heading.
return one[0] + one.substring(1).toLowerCase();
}
return switch (this) {
StopKind.pickup => 'Pickup',
StopKind.delivery => 'Delivery',
StopKind.combined => 'Pickup & Delivery',
};
}
/// Label for the small chip on a stop card — **both** stop cards.
///
@@ -229,11 +301,17 @@ extension StopKindUi on StopKind {
/// stop cards finally agree. The full wording survives where there is room
/// for it — the detail sheet and Activity's Type row — which is what
/// [badgeLabel] is still for.
String get chipLabel => switch (this) {
StopKind.pickup => 'PICKUP',
StopKind.delivery => 'DELIVERY',
StopKind.combined => 'P&D',
};
/// One label all day on a service tenant, and it is the shorter of the two
/// reasons this getter is profile-aware: the other is that a rider whose
/// morning says PICKUP and whose afternoon says DELIVERY will go looking for
/// a difference in how he handles the bag, and there isn't one.
String get chipLabel =>
ServiceProfile.active.workTypeLabel ??
switch (this) {
StopKind.pickup => 'PICKUP',
StopKind.delivery => 'DELIVERY',
StopKind.combined => 'P&D',
};
/// Location-row label.
String get locationLabel => switch (this) {
@@ -301,8 +379,7 @@ extension StopLegUi on StopLeg {
String get proofHint =>
isDelivery ? 'OTP + photo required' : 'Photo + weight required';
IconData get icon =>
isDelivery ? Icons.local_shipping_rounded : Icons.inventory_2_rounded;
IconData get icon => isDelivery ? LucideIcons.truck : LucideIcons.package;
Color get accent =>
isDelivery ? ColorConstants.deliveryAccent : ColorConstants.pickupAccent;

File diff suppressed because it is too large Load Diff