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:
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
710
lib/views/Dashboard/pickups/delivery_actions.dart
Normal file
710
lib/views/Dashboard/pickups/delivery_actions.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
358
lib/views/Dashboard/pickups/delivery_proof_page.dart
Normal file
358
lib/views/Dashboard/pickups/delivery_proof_page.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
904
lib/views/Dashboard/pickups/shipment_capture.dart
Normal file
904
lib/views/Dashboard/pickups/shipment_capture.dart
Normal 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 }
|
||||
702
lib/views/Dashboard/pickups/shipment_review.dart
Normal file
702
lib/views/Dashboard/pickups/shipment_review.dart
Normal 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,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user