Files
doormile_milderapp/lib/Models/stop_status.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

272 lines
11 KiB
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';
/// Canonical lifecycle status for a booking/stop, parsed from the backend's
/// free-form `orderstatus` string.
///
/// Status used to be compared as raw string literals in ~90 places, with
/// case-sensitivity landmines (`'active'` vs `'ACTIVE'`) and variant spellings
/// (`'picked'` / `'picked up'` / `'pickuped'`). This normalizes all of that in
/// one place so filters agree everywhere.
///
/// NOTE: this is the *read* side. The status-change API still sends its own
/// protocol verbs (`ACCEPTED`/`ARRIVED`/`PICKED`/`REJECTED`/`CANCELLED`) — see
/// [OrderAction] for those canonical write values.
enum StopStatus {
newStop,
assigned,
accepted,
active,
arrived,
picked,
/// ── The milk run's second half ──
///
/// [picked] is where a logistics stop ends: the parcel is collected and it
/// becomes the hub's problem. A milk run does not end there — collection is
/// the middle of the day, and the rider still has to carry the load to the
/// customers.
///
/// So these three rungs exist for the line that has them, and only that line
/// puts a stop into them. Keeping them in the same enum is what lets one
/// `stopStatusOf` test serve both halves of the day; keeping them *distinct
/// from* [picked] and [arrived] is what stops a collected crate being
/// reported as fifteen delivered lunches ninety minutes early.
///
/// The load is released and the rider is driving his round.
outForDelivery,
/// At a customer's door — not at a source. The distinction matters: the
/// pickup [arrived] and this one are different places, different actions and
/// different next steps.
deliveryArrived,
/// Handed over. Terminal for a milk-run stop, the way [picked] is terminal
/// for a logistics one.
delivered,
skipped,
cancelled,
rejected,
unknown,
}
/// Normalizes any raw `orderstatus` value (case-insensitive, trimmed, variant
/// spellings folded) into a [StopStatus].
StopStatus stopStatusFromRaw(dynamic raw) {
final s = (raw?.toString() ?? '').trim().toLowerCase();
switch (s) {
case 'new':
return StopStatus.newStop;
case 'assigned':
case 'miler_assigned':
case 'pending':
return StopStatus.assigned;
case 'accepted':
return StopStatus.accepted;
case 'active':
return StopStatus.active;
case 'arrived':
return StopStatus.arrived;
case 'picked':
case 'picked up':
case 'pickuped':
case 'pickedup':
return StopStatus.picked;
// The milk run's delivery rungs. `out_for_delivery` is also the backend's
// own consignment status, spelled its way, so a stop stamped from either
// side folds to the same state.
case 'outfordelivery':
case 'out_for_delivery':
case 'out for delivery':
case 'delivering':
return StopStatus.outForDelivery;
case 'deliveryarrived':
case 'delivery_arrived':
case 'at customer':
case 'at_customer':
return StopStatus.deliveryArrived;
case 'delivered':
return StopStatus.delivered;
case 'skipped':
return StopStatus.skipped;
case 'cancelled':
case 'canceled':
return StopStatus.cancelled;
case 'rejected':
return StopStatus.rejected;
default:
return StopStatus.unknown;
}
}
/// Parse straight from a booking map's `orderstatus`.
StopStatus stopStatusOf(Map<String, dynamic> booking) =>
stopStatusFromRaw(booking['orderstatus']);
extension StopStatusX on StopStatus {
bool get isPicked => this == StopStatus.picked;
bool get isCancelled => this == StopStatus.cancelled;
bool get isSkipped => this == StopStatus.skipped;
bool get isActive => this == StopStatus.active;
bool get isRejected => this == StopStatus.rejected;
/// On the customer round: released, at a door, or handed over.
bool get isDeliveryLeg =>
this == StopStatus.outForDelivery ||
this == StopStatus.deliveryArrived ||
this == StopStatus.delivered;
/// Handed to the customer. Terminal on a milk run.
bool get isDelivered => this == StopStatus.delivered;
/// ── "Is this stop finished?" is a question about the LINE ──
///
/// This is the distinction that broke the milk run, so it is worth being
/// exact about.
///
/// On **logistics**, collecting the parcel *is* the job: [picked] is the end,
/// the booking becomes a consignment, and the hub takes it from there.
///
/// On a **milk run**, [picked] is the *middle of the morning*. The rider is
/// holding fifteen lunches and has not delivered one of them. His day ends at
/// [delivered], one customer at a time.
///
/// One boolean answered both, and it answered "picked = finished". So a
/// milk-run order collected at a kitchen was dropped from the deliveries list
/// as completed work and filed on Activity as history — before the rider had
/// left the counter. He collected five lunches and watched them disappear
/// into his own history.
///
/// Read this, not [isTerminal], anywhere the question is "should this stop
/// still be worked today?".
bool get isWorkComplete => ServiceProfile.active.deliversToCustomer
? (this == StopStatus.delivered || this == StopStatus.cancelled)
: (this == StopStatus.picked ||
this == StopStatus.delivered ||
this == StopStatus.cancelled);
/// Every state that ends a stop on *some* line, without asking which.
///
/// Only for code that must not depend on the active profile — a pure store
/// helper, say. Screens want [isWorkComplete].
bool get isTerminal =>
this == StopStatus.picked ||
this == StopStatus.delivered ||
this == StopStatus.cancelled;
/// The old name for [isWorkComplete], kept because ~8 call sites read it and
/// they all want the line-aware answer.
bool get isFinishedPickup => isWorkComplete;
/// Still awaiting the rider's acceptance — belongs on Home, not Bookings.
bool get isPending =>
this == StopStatus.newStop || this == StopStatus.assigned;
/// What this state is *called*, in the rider's words.
///
/// The status vocabulary had been re-invented per widget: the live banner had
/// its own switch over raw `orderstatus` strings returning "At pickup" and
/// "In progress", the trip card wrote 'Accepted' as a literal, and the two
/// disagreed about the same backend state. A rider moving between Home, the
/// banner and Bookings was reading three vocabularies for one lifecycle and
/// re-learning the app at each stop.
///
/// One list, here, next to the parser that produces the states — so a new
/// status cannot be added without someone deciding what to call it.
String get label => switch (this) {
StopStatus.newStop => 'New',
StopStatus.assigned => 'Assigned',
StopStatus.accepted => 'Accepted',
StopStatus.active => 'In progress',
StopStatus.arrived => 'At the stop',
StopStatus.picked => 'Picked up',
StopStatus.outForDelivery => 'Out for delivery',
StopStatus.deliveryArrived => 'At the customer',
StopStatus.delivered => 'Delivered',
StopStatus.skipped => 'Skipped',
StopStatus.cancelled => 'Cancelled',
StopStatus.rejected => 'Rejected',
StopStatus.unknown => 'Active',
};
/// The colour that name is drawn in.
///
/// Next to [label] for the same reason the labels are here: a status cannot
/// be added without somebody deciding both what it is called and how loud it
/// is. Semantic, not decorative — green means the rider is done with it, red
/// means it needs him now, grey means it is waiting on somebody else.
Color get color => switch (this) {
StopStatus.newStop || StopStatus.assigned => ColorConstants.secondaryText,
StopStatus.accepted => ColorConstants.acceptGreen,
StopStatus.active || StopStatus.arrived => ColorConstants.pickupAccent,
StopStatus.picked => ColorConstants.acceptGreen,
// ── Loud, but in the leg's own colour ──
//
// These wore the brand red, and on the stop sheet that word sat between a
// blue leg disc and a blue "what to do here" chip, one line above a red
// Navigate button — a status dressed as an action, on a screen that codes
// its delivery leg blue everywhere else. The round is still drawn loudly;
// it is drawn in the ink that already means *delivery leg*, which is the
// same rule that keeps the pickup-leg states on the pickup accent above.
StopStatus.outForDelivery ||
StopStatus.deliveryArrived => ColorConstants.deliveryAccent,
StopStatus.delivered => ColorConstants.acceptGreen,
StopStatus.skipped => ColorConstants.warning,
StopStatus.cancelled || StopStatus.rejected => ColorConstants.errorRed,
StopStatus.unknown => ColorConstants.secondaryText,
};
/// A glyph for the same state, so the tag never depends on colour alone —
/// these are read in sunlight, through a scratched screen, at a gate.
IconData get icon => switch (this) {
StopStatus.newStop || StopStatus.assigned => LucideIcons.clock,
StopStatus.accepted => LucideIcons.circleCheck,
StopStatus.active => LucideIcons.bike,
StopStatus.arrived => LucideIcons.mapPin,
StopStatus.picked => LucideIcons.package,
StopStatus.outForDelivery => LucideIcons.truck,
StopStatus.deliveryArrived => LucideIcons.mapPin,
// ── Delivered is settled, not decorated ──
//
// `verified_rounded` is a starburst badge — the shape Material reserves
// for *verified account*, and the loudest glyph in the set. Down a column
// of finished stops it made every completed delivery look like an award.
// A package with a tick on it says the same thing about the same object,
// quietly, and it is the mark every delivery app in the world uses.
StopStatus.delivered => LucideIcons.packageCheck,
// Skipped must not read as a variant of delivered. A circle with a stroke
// through it is the "attempted, did not happen" mark; `replay` promised a
// retry the rider may not actually be able to make.
StopStatus.skipped => LucideIcons.circleSlash,
StopStatus.cancelled || StopStatus.rejected => LucideIcons.ban,
StopStatus.unknown => LucideIcons.circle,
};
}
/// Canonical protocol verbs sent to the status-change API (the *write* side).
class OrderAction {
static const String accept = 'ACCEPT';
static const String accepted = 'ACCEPTED';
static const String arrived = 'ARRIVED';
static const String picked = 'PICKED';
static const String rejected = 'REJECTED';
static const String cancelled = 'CANCELLED';
/// The milk run's second half. These are *client* verbs — they name what the
/// rider did, and each one maps to a real backend call rather than to a
/// status string the API would not recognise:
///
/// startDelivery → POST /miler/deliveries/start
/// deliveryArrived → (local; the round has no per-stop arrival route)
/// delivered → POST /miler/consignments/:id/deliver
///
/// See the mapping table in `MilkRun`.
static const String startDelivery = 'START_DELIVERY';
static const String deliveryArrived = 'DELIVERY_ARRIVED';
static const String delivered = 'DELIVERED';
}