Files
doormile_milderapp/lib/views/Dashboard/pickups/skip_sheet.dart
Thiru-tenext d7348e253f 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>
2026-08-22 05:40:35 +05:30

369 lines
16 KiB
Dart

part of 'pickups.dart';
Future<void> _showMyOptionsSheet(
BuildContext context,
Map<String, dynamic> pickup,
_MyPickupsState? parentState,
) async {
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',
];
// ── 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 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;
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,
),
),
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,
),
),
child: Row(
children: [
Icon(
LucideIcons.info,
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.w500,
color: ColorConstants.rewardAmber,
fontFamily: FontConstants.fontFamily,
height: 1.3,
),
),
),
],
),
),
SizedBox(height: 24.h),
Row(
children: [
Expanded(
child: MilerButton(
label: "Cancel",
onPressed: () =>
Navigator.pop(context, false),
variant: MilerButtonVariant.outlined,
color: ColorConstants.onSurfaceVariant,
height: ButtonSizes.secondary,
),
),
SizedBox(width: 12.w),
Expanded(
child: MilerButton(
label: "Confirm skip",
onPressed: () => Navigator.pop(context, true),
color: ColorConstants.errorRed,
height: ButtonSizes.secondary,
),
),
],
),
],
),
),
);
},
);
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,
),
],
),
),
);
},
);
},
);
}