Files
doormile_customer_app/lib/data/models.dart
Thiru-tenext 8207e27a97 Booking and sign-in flow, and the offline build back under guard
Picks up where 0d66627 left off. Three things.

SIGN-IN, CUT TO THE QUESTION IT ASKS
It opened with a quarter-screen crimson hero carrying a lockup, a CUSTOMER
badge, a display-size promise and a sub-line, then a Phone/Email toggle, then a
labelled field, then a card explaining what a verification code is. Six things
to read before the one thing to do.

Nobody arrives at a sign-in screen needing to be sold the product. It is a
heading, a field and a button now, which is where Uber, Bolt and Porter put
them. The toggle became one quiet line under the field — choosing a method was
the first decision on the screen, before the customer had seen what was being
asked, and almost everyone uses the phone. The privacy card went: it explained
that a code would be sent, which the next screen demonstrates a second later.

`DmTextField.label` is nullable for this — "Enter your mobile number" above a
field captioned "Phone number" is one sentence printed twice.

BOOKING, DOWN TO ONE SCREENFUL
Landmark, recipient name and recipient phone are all optional and were all
drawn at the weight of the two fields that are not, putting six rows of "you
may skip this" between the address and the button. They fold behind one row
that counts what is filled in rather than just saying "optional".

Four crimson section heads became one. An accent used five times on a screen is
not an accent; crimson now marks the destination, which is the only choice that
changes the price.

Together those put the window, the package count and the CTA above the fold.

THE OFFLINE BUILD, BACK, UNDER TWO RULES
Deleted on 15 Sep after it cost two rounds of hunting for bookings in the admin
console that had never left the phone. That was not caused by the fake
existing — it was caused by a fake that did not announce itself and that
nothing stopped from shipping. Both are closed:

  * `useDevData` is false in a release whatever the defines say;
  * `describe` leads with DEV DATA (offline) and shows "no network" rather than
    a host the build never contacts.

It is opt-in — `flutter run` still talks to the real API — which is the
property whose absence caused the original mess. `devAutoLogin` is deliberately
false under FLUTTER_TEST so the widget tests keep driving the real entrance.

    flutter run --dart-define=DM_MOCK=true

86 tests green, analyze clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-21 13:10:28 +05:30

1250 lines
41 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// Domain models for the Doormile customer app.
///
/// Product model: the customer creates a **pickup booking**, not a shipment.
/// Serviceable state + district are required; the exact address, map pin and
/// recipient details are optional and may be completed by the Miler at pickup.
library;
/// One journey stage as the customer sees it. Raw backend status keys are
/// mapped onto this enum and never rendered directly.
enum JourneyStage {
booked(
'booked',
'Pickup booked',
'Pickup booked',
"We're finding a Miler near you.",
),
assigned(
'assigned',
'Miler assigned',
'Miler assigned',
'Your Miler will arrive in your pickup window.',
),
onTheWay(
'on_the_way',
'Miler on the way',
'Miler on the way',
'Keep your package ready.',
),
arrived(
'arrived',
'Miler arrived',
'Miler has arrived',
'Hand over the package to complete pickup.',
),
pickedUp(
'picked_up',
'Package picked up',
'Package picked up',
'Your Miler has the package.',
),
orderCreated(
'order_created',
'Order created',
'Order created',
'Details confirmed. Your package is on its way into our network.',
),
inTransit(
'in_transit',
'In transit',
'In transit',
'Moving towards the destination district.',
),
outForDelivery(
'out_for_delivery',
'Out for delivery',
'Out for delivery',
'Arriving today at the delivery address.',
),
delivered(
'delivered',
'Delivered',
'Delivered',
'Thanks for sending with Doormile.',
);
const JourneyStage(this.key, this.label, this.headline, this.sub);
/// The backend key this stage maps from.
final String key;
/// Timeline label.
final String label;
/// Dominant headline on the tracking screen.
final String headline;
/// Supporting line under the headline.
final String sub;
int get step => index;
/// An unrecognised key falls back to [booked] rather than throwing.
///
/// That is deliberate but it is also silent, so it is worth naming what it
/// hides: seeing "Pickup booked" on a parcel that is plainly moving means the
/// backend shipped a stage this build does not know — a failed-delivery key,
/// most likely, which v1 has no room for. It is a missing client release, not
/// a backend bug.
static JourneyStage fromKey(String? key) => JourneyStage.values.firstWhere(
(s) => s.key == key,
orElse: () => JourneyStage.booked,
);
static JourneyStage at(int i) =>
JourneyStage.values[i.clamp(0, JourneyStage.values.length - 1)];
/// What a status pill should say. Operational stages roll up so the customer
/// never reads "Order created" as their status.
String get milestoneLabel => CustomerMilestone.of(this).label;
}
/// What the customer sees on the timeline.
///
/// The backend keeps nine operational states; the customer gets seven
/// meaningful ones. Operational detail ("Miler on the way", "Order created")
/// appears as context under the current milestone rather than as another
/// permanent row.
enum CustomerMilestone {
booked('Pickup booked'),
assigned('Miler assigned'),
pickupInProgress('Pickup in progress'),
collected('Package collected'),
inTransit('In transit'),
outForDelivery('Out for delivery'),
delivered('Delivered');
const CustomerMilestone(this.label);
final String label;
/// Which milestone an internal stage rolls up into.
static CustomerMilestone of(JourneyStage stage) => switch (stage) {
JourneyStage.booked => CustomerMilestone.booked,
JourneyStage.assigned => CustomerMilestone.assigned,
JourneyStage.onTheWay || JourneyStage.arrived =>
CustomerMilestone.pickupInProgress,
JourneyStage.pickedUp || JourneyStage.orderCreated =>
CustomerMilestone.collected,
JourneyStage.inTransit => CustomerMilestone.inTransit,
JourneyStage.outForDelivery => CustomerMilestone.outForDelivery,
JourneyStage.delivered => CustomerMilestone.delivered,
};
/// The earliest internal stage in this milestone — used for its timestamp.
JourneyStage get firstStage => JourneyStage.values.firstWhere(
(s) => CustomerMilestone.of(s) == this,
);
}
enum BookingStatus {
active,
completed,
cancelled;
/// The backend sends this explicitly rather than making us infer it. An
/// unrecognised value means a status shipped without a client release, and
/// `active` is the safe reading: it keeps the booking visible and tracked
/// rather than filing it away as finished.
static BookingStatus fromKey(String? key) => switch (key) {
'completed' => BookingStatus.completed,
'cancelled' || 'canceled' => BookingStatus.cancelled,
_ => BookingStatus.active,
};
}
/// Epoch milliseconds, UTC, as every timestamp on this surface is sent.
///
/// Tolerant of the two other shapes a JSON encoder can produce for the same
/// instant — a numeric string, and an ISO-8601 string with an offset — because
/// the alternative to tolerating them is a crash on the tracking screen. A
/// naive local string with no offset is deliberately **not** accepted: it is
/// ambiguous by 5h30m here, and guessing is how "yesterday's work shown as
/// today" happened on the Miler app.
DateTime? epochMillis(dynamic value) {
if (value == null) return null;
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
if (value is num) return DateTime.fromMillisecondsSinceEpoch(value.toInt());
if (value is String) {
final asInt = int.tryParse(value);
if (asInt != null) return DateTime.fromMillisecondsSinceEpoch(asInt);
final parsed = DateTime.tryParse(value);
if (parsed != null && (value.contains('+') || value.endsWith('Z'))) {
return parsed.toLocal();
}
}
return null;
}
/// Reads a coordinate that the wire may spell either way.
///
/// The contract documents `latitude`/`longitude` on every payload that carries
/// a point — the booking's pickup, each destination, the fare's pickup — so
/// that is what the client sends. It still accepts the short form on the way
/// in: the district catalogue and the saved-locations rows use it, and a
/// booking that silently loses its pin is a tracking screen with no map on it.
double? coordinate(Map<String, dynamic> json, String key) {
final long = key == 'lat' ? 'latitude' : 'longitude';
final value = json[long] ?? json[key];
return (value as num?)?.toDouble();
}
/// What `POST /customer/auth/otp/request` answers with.
///
/// The server owns both numbers, and the OTP screen is built from them rather
/// than from constants: a backend that moves to a six-digit code, or lengthens
/// the resend window, must not need an app release to be usable.
class OtpChallenge {
const OtpChallenge({
this.codeLength = 4,
this.resendAfterSeconds = 30,
this.sent = true,
});
/// How many digits the code has.
final int codeLength;
/// How long before "Resend code" is offered again.
final int resendAfterSeconds;
/// False when the server accepted the request but did not deliver a code —
/// worth saying out loud rather than leaving somebody waiting for an SMS
/// that is not coming.
final bool sent;
/// The shape to assume when the server did not describe the challenge.
static const OtpChallenge unspecified = OtpChallenge();
factory OtpChallenge.fromJson(Map<String, dynamic> json) => OtpChallenge(
// Clamped to what the screen can lay out: a server answering 40 must not
// produce a row of boxes narrower than a finger.
codeLength: ((json['codeLength'] as num?)?.toInt() ?? 4).clamp(4, 8),
resendAfterSeconds:
((json['resendAfterSeconds'] as num?)?.toInt() ?? 30).clamp(0, 600),
sent: json['sent'] as bool? ?? true,
);
}
/// A serviceable state, as returned by `GET /serviceability/states`.
class ServiceArea {
const ServiceArea({
required this.code,
required this.name,
required this.districtCount,
this.transitTag,
});
final String code;
final String name;
final int districtCount;
/// Short promise shown beside the state picker, e.g. "Ultra-fast transit".
final String? transitTag;
bool get hasOpenDistricts => districtCount > 0;
factory ServiceArea.fromJson(Map<String, dynamic> json) => ServiceArea(
code: json['code'] as String,
name: json['name'] as String,
districtCount: json['districtCount'] as int? ?? 0,
transitTag: json['transitTag'] as String?,
);
}
/// A district inside a serviceable state.
/// One serviceable city: a district, plus the state it belongs to.
///
/// The API serves coverage as a hierarchy and the send screen offers it as one
/// flat strip, so something has to carry the state along with the district —
/// `POST /customer/bookings` wants both codes on every destination, and a
/// district code alone cannot be sent.
class CityOption {
const CityOption({required this.state, required this.district});
final ServiceArea state;
final District district;
/// "Chennai, Tamil Nadu" — what the rest of the app already calls a
/// destination.
String get label => '${district.name}, ${state.name}';
/// The one line under the name in the strip: what this city is promised,
/// falling back to the state's transit tag when the district has no promise
/// of its own. Null when the backend told us neither.
String? get note => district.promise ?? state.transitTag;
}
class District {
const District({
required this.code,
required this.name,
required this.available,
this.note,
this.hub,
this.promise,
this.lat,
this.lng,
});
final String code;
final String name;
final bool available;
final String? note;
/// Delivery hub serving this district, e.g. "Coimbatore Central Hub".
final String? hub;
/// What that hub guarantees, e.g. "Next-day delivery".
final String? promise;
/// Where that hub sits, so the route map can draw a real line to it.
/// Backend-served; null simply means no line is drawn.
final double? lat;
final double? lng;
bool get hasLocation => lat != null && lng != null;
factory District.fromJson(Map<String, dynamic> json) => District(
code: json['code'] as String,
name: json['name'] as String,
available: json['available'] as bool? ?? true,
note: json['note'] as String?,
hub: json['hub'] as String?,
promise: json['promise'] as String?,
lat: (json['lat'] as num?)?.toDouble(),
lng: (json['lng'] as num?)?.toDouble(),
);
}
/// A pickup window offered by the backend.
class PickupSlot {
const PickupSlot({
required this.id,
required this.day,
required this.window,
required this.available,
this.note,
this.tag,
this.milersNearby = 0,
this.caption,
});
final String id;
final String day;
final String window;
final bool available;
final String? note;
/// Highlight badge, e.g. "Fastest pickup".
final String? tag;
/// How many Milers are currently in the zone for this window.
final int milersNearby;
/// One-line description, e.g. "Arriving in approx. 45 mins".
final String? caption;
/// "Today · 2:00 – 4:00 PM"
String get label => '$day · $window';
factory PickupSlot.fromJson(Map<String, dynamic> json) => PickupSlot(
id: json['id'] as String,
day: json['day'] as String,
window: json['window'] as String,
available: json['available'] as bool? ?? true,
note: json['note'] as String?,
tag: json['tag'] as String?,
milersNearby: json['milersNearby'] as int? ?? 0,
caption: json['caption'] as String?,
);
}
/// A pickup or delivery place.
class Place {
const Place({required this.title, required this.sub, this.lat, this.lng});
final String title;
final String sub;
final double? lat;
final double? lng;
/// True when this place can be shown on a map.
bool get hasLocation => lat != null && lng != null;
/// The contract guarantees `title` and `sub`; this parser still does not
/// insist on them. A pickup that renders with a thin label is a cosmetic
/// problem — a booking detail that throws is a customer who cannot see their
/// parcel at all, and staging has not been proven yet.
factory Place.fromJson(Map<String, dynamic> json) => Place(
title: (json['title'] as String?)?.trim().isNotEmpty == true
? json['title'] as String
: 'Pickup point',
sub: (json['sub'] as String?) ?? '',
lat: coordinate(json, 'lat'),
lng: coordinate(json, 'lng'),
);
Map<String, dynamic> toJson() => {
'title': title,
'sub': sub,
'latitude': ?lat,
'longitude': ?lng,
};
/// `POST /customer/bookings` ▸ `pickup`.
///
/// Carries who the Miler asks for at the door. That is the signed-in
/// customer unless the caller names somebody else, and it is sent rather
/// than left to the server to look up, because the contract asks for it.
Map<String, dynamic> toBookingJson({
String? contactName,
String? contactPhone,
}) => {
...toJson(),
'contactName': ?contactName,
'contactPhone': ?contactPhone,
};
}
/// The only required destination information: a serviceable state + district.
class Destination {
Destination({
this.stateCode,
this.stateName,
this.districtCode,
this.districtName,
});
String? stateCode;
String? stateName;
String? districtCode;
String? districtName;
/// Whether this destination can be **booked**. The codes are what the
/// booking request carries, so the form gates on them.
bool get isComplete => stateCode != null && districtCode != null;
/// "Coimbatore, Tamil Nadu".
///
/// Read from the **names**, and deliberately not gated on [isComplete]: the
/// booking response names the state and district and sends no codes back at
/// all, so gating this on the codes rendered every read-back destination in
/// the app — Orders rows, tracking, the receipt — as an em dash.
String get label {
final named = <String>[?_clean(districtName), ?_clean(stateName)];
if (named.isNotEmpty) return named.join(', ');
final coded = <String>[?_clean(districtCode), ?_clean(stateCode)];
return coded.isEmpty ? '—' : coded.join(', ');
}
static String? _clean(String? v) {
final t = v?.trim();
return (t == null || t.isEmpty) ? null : t;
}
Destination copy() => Destination(
stateCode: stateCode,
stateName: stateName,
districtCode: districtCode,
districtName: districtName,
);
/// `stateName` and `districtName` are contract-guaranteed, because the client
/// renders "Chennai, Tamil Nadu" from them and never looks a code up.
factory Destination.fromJson(Map<String, dynamic> json) => Destination(
stateCode: json['stateCode'] as String?,
stateName: json['stateName'] as String?,
districtCode: json['districtCode'] as String?,
districtName: json['districtName'] as String?,
);
/// Only the codes are sent back — the server owns the names.
Map<String, dynamic> toJson() => {
'stateCode': ?stateCode,
'districtCode': ?districtCode,
};
}
class MapPin {
const MapPin(this.lat, this.lng);
final double lat;
final double lng;
static MapPin? fromJson(Map<String, dynamic>? json) {
if (json == null) return null;
final lat = coordinate(json, 'lat');
final lng = coordinate(json, 'lng');
if (lat == null || lng == null) return null;
return MapPin(lat, lng);
}
Map<String, dynamic> toJson() => {'latitude': lat, 'longitude': lng};
}
/// Everything here is optional at booking time. The Miler fills the gaps
/// during pickup, which is why nothing in this class is required.
class DeliveryDetails {
DeliveryDetails({
this.street,
this.building,
this.landmark,
this.recipientName,
this.recipientPhone,
this.instructions,
this.pin,
});
String? street;
String? building;
String? landmark;
String? recipientName;
String? recipientPhone;
String? instructions;
MapPin? pin;
static String? _clean(String? v) {
final t = v?.trim();
return (t == null || t.isEmpty) ? null : t;
}
void update({
String? street,
String? building,
String? landmark,
String? recipientName,
String? recipientPhone,
String? instructions,
}) {
this.street = _clean(street);
this.building = _clean(building);
this.landmark = _clean(landmark);
this.recipientName = _clean(recipientName);
this.recipientPhone = _clean(recipientPhone);
this.instructions = _clean(instructions);
}
/// "3B, 4th Street, Near bus stand, Map pin dropped" — null when nothing set.
String? get exactAddress {
final parts = <String>[
?building,
?street,
?landmark,
if (pin != null) 'Map pin dropped',
];
return parts.isEmpty ? null : parts.join(', ');
}
/// "Meera S · 9884412210" — null when nothing set.
String? get recipient {
final parts = <String>[?recipientName, ?recipientPhone];
return parts.isEmpty ? null : parts.join(' · ');
}
DeliveryDetails copy() => DeliveryDetails(
street: street,
building: building,
landmark: landmark,
recipientName: recipientName,
recipientPhone: recipientPhone,
instructions: instructions,
pin: pin,
);
/// Reads the details from either shape the wire uses.
///
/// The contract carries them **flat on the destination** — `recipientName`,
/// `building`, `street`, `landmark`, `latitude`, `longitude` — so the caller
/// hands this the destination row itself when there is no nested `details`
/// object, and the pin is then read off the row's own coordinates.
factory DeliveryDetails.fromJson(Map<String, dynamic>? json) {
if (json == null) return DeliveryDetails();
return DeliveryDetails(
street: _clean(json['street'] as String?),
building: _clean(json['building'] as String?),
landmark: _clean(json['landmark'] as String?),
recipientName: _clean(json['recipientName'] as String?),
recipientPhone: _clean(json['recipientPhone'] as String?),
instructions: _clean(json['instructions'] as String?),
pin:
MapPin.fromJson(json['pin'] as Map<String, dynamic>?) ??
MapPin.fromJson(json),
);
}
/// Empty when nothing was filled in, so a booking request omits `details`
/// entirely rather than sending a map of nulls.
bool get isEmpty =>
street == null &&
building == null &&
landmark == null &&
recipientName == null &&
recipientPhone == null &&
instructions == null &&
pin == null;
/// The recipient and address fields exactly as a booking's destination
/// carries them.
///
/// [instructions] is deliberately absent: the contract has no per-destination
/// note, it has one `remarks` line for the whole visit, and that is where
/// [LiveDoormileApi] sends it. Repeating it here would put the same sentence
/// on the wire twice under a key the server does not read.
Map<String, dynamic> toJson() => {
'recipientName': ?recipientName,
'recipientPhone': ?recipientPhone,
'building': ?building,
'street': ?street,
'landmark': ?landmark,
'latitude': ?pin?.lat,
'longitude': ?pin?.lng,
};
/// `PATCH /customer/bookings/{reference}/destinations/{index}`.
///
/// Flat and in the destination's own vocabulary, like every other place the
/// contract carries a recipient. A `null` **clears** the field rather than
/// being omitted, so every key is sent whether or not it has a value —
/// otherwise a customer could add a landmark but never remove one.
Map<String, dynamic> toPatchJson() => {
'recipientName': recipientName,
'recipientPhone': recipientPhone,
'building': building,
'street': street,
'landmark': landmark,
'instructions': instructions,
'latitude': pin?.lat,
'longitude': pin?.lng,
};
}
/// A Miler (pickup) or delivery agent.
class Person {
const Person({
required this.name,
required this.vehicle,
required this.phone,
this.rating,
this.trips,
this.vehicleType,
});
final String name;
final String vehicle;
final String phone;
/// Customer rating out of 5, when the backend sent one. **Null is not 4.9** —
/// a default here would put an invented reputation on a real person's card.
final double? rating;
/// Completed pickups, for the "1240+ pickups" line. Null when unknown.
final int? trips;
/// "E-Scooter". Null when the backend did not say.
final String? vehicleType;
/// "4.9 · 1240+ pickups", or null when there is nothing true to say.
String? get credentials {
final parts = <String>[
if (rating != null) '$rating',
if (trips != null && trips! > 0) '$trips+ pickups',
];
return parts.isEmpty ? null : parts.join(' · ');
}
String get initials {
final parts = name.trim().split(RegExp(r'\s+'));
final letters = parts.map((p) => p.isEmpty ? '' : p[0]).join();
return letters
.substring(0, letters.length < 2 ? letters.length : 2)
.toUpperCase();
}
/// Null rather than a blank card: a Miler with no name is not a Miler the
/// customer should be shown waiting for.
static Person? fromJson(Map<String, dynamic>? json) {
if (json == null) return null;
final name = (json['name'] as String?)?.trim();
if (name == null || name.isEmpty) return null;
return Person(
name: name,
vehicle: (json['vehicle'] as String?) ?? '',
phone: (json['phone'] as String?) ?? '',
rating: (json['rating'] as num?)?.toDouble(),
trips: (json['trips'] as num?)?.toInt(),
vehicleType: (json['vehicleType'] as String?),
);
}
}
/// Caps on a single pickup, served by the backend so they can change without
/// an app release. Nothing in the UI hardcodes these numbers.
///
/// ── Why the default is 1 and not 5 ──
///
/// Multi-destination is server-gated: a pickup that fans out into several
/// orders is undeliverable until the Miler build keys its work on
/// `consignmentid`, so the backend advertises `maxDestinations: 1` until then.
/// A client that fell back to 5 when the config call failed would hand a
/// customer a booking the network cannot complete — a stranded parcel bought
/// with one dropped request. The safe cap is the low one, so that is the
/// default. See `GET /customer/config/booking-limits`.
class BookingLimits {
const BookingLimits({
this.maxPackages = 20,
this.maxDestinations = 1,
this.maxCodAmount = 0,
});
final int maxPackages;
final int maxDestinations;
/// The largest cash-on-delivery amount a destination may carry, in whole
/// rupees. **Zero means the server is not offering COD**, which is also the
/// default: this app has no COD entry field yet, so the cap is read and
/// carried rather than assumed. See [allowsCod].
final int maxCodAmount;
/// Clamped to at least 1: a server answering `0` must not leave the customer
/// with a booking form that can never be completed.
factory BookingLimits.fromJson(Map<String, dynamic> json) => BookingLimits(
maxPackages: ((json['maxPackages'] as num?)?.toInt() ?? 20).clamp(1, 999),
maxDestinations: ((json['maxDestinations'] as num?)?.toInt() ?? 1).clamp(
1,
999,
),
maxCodAmount: ((json['maxCodAmount'] as num?)?.toInt() ?? 0).clamp(
0,
1000000,
),
);
/// True when the backend will accept a `codAmount` at all.
bool get allowsCod => maxCodAmount > 0;
/// True when the customer may send to more than one place at all. Drives
/// whether "Send to another place" is offered — a permanently disabled
/// control is worse than an absent one.
bool get allowsMultipleDestinations => maxDestinations > 1;
}
/// What the Miler recorded at the door.
///
/// Weight and photographs are captured during pickup verification, which is
/// also when the price settles — so this is the customer's evidence of exactly
/// what was collected.
class ParcelVerification {
const ParcelVerification({
required this.weightKg,
required this.photos,
required this.capturedAt,
this.capturedBy,
});
final double weightKg;
/// One reference per package. A URL once photos are stored remotely.
final List<String> photos;
final DateTime capturedAt;
/// The Miler who weighed and photographed it.
final String? capturedBy;
String get weightLabel => '${weightKg.toStringAsFixed(1)} kg';
/// Null when the parcel has not been weighed yet. A verification block with
/// no weight is not evidence, so it is not shown as any.
static ParcelVerification? fromJson(Map<String, dynamic>? json) {
if (json == null) return null;
final weight = (json['weightKg'] as num?)?.toDouble();
if (weight == null) return null;
return ParcelVerification(
weightKg: weight,
photos: (json['photos'] as List? ?? const [])
.whereType<String>()
.toList(),
capturedAt: epochMillis(json['capturedAt']) ?? DateTime.now(),
capturedBy: json['capturedBy'] as String?,
);
}
}
/// One place the customer is sending packages to, inside a pickup booking.
///
/// State and district are the only required parts. Everything else is either
/// optional at booking time or filled in by the backend after collection: a
/// group becomes its own tracked order once the Miler completes the pickup.
class DestinationGroup {
DestinationGroup({
Destination? destination,
this.packageCount = 1,
DeliveryDetails? details,
this.trackingId,
this.stage,
this.index,
this.codAmount,
}) : destination = destination ?? Destination(),
details = details ?? DeliveryDetails();
final Destination destination;
/// The server's position for this stop, which is what
/// `PATCH …/destinations/{index}` addresses. Null on a draft that has not
/// been booked yet, where the list position is the only ordering there is.
int? index;
/// Cash to collect from the recipient, in whole rupees. Null when there is
/// none — and there never is yet, because no screen offers to set one. It is
/// parsed and echoed back so a booking created elsewhere does not lose it.
int? codAmount;
/// How many packages go to this place. Never shown as "pieces" or "MPS".
int packageCount;
/// Optional exact address and recipient.
DeliveryDetails details;
/// Assigned only after the backend creates the order at pickup.
String? trackingId;
/// Its own journey, once collected. Null until the order exists.
JourneyStage? stage;
/// Weight and photos recorded by the Miler at pickup. Null until collected.
ParcelVerification? verification;
/// The hub serving this district, when the backend told us.
District? district;
bool get isComplete => destination.isComplete;
/// The serving hub as something a map can draw. Null until the backend has
/// told us where the district is, in which case no line is drawn.
Place? get hub => district != null && district!.hasLocation
? Place(
title: district!.hub ?? destination.districtName ?? 'Destination',
sub: district!.promise ?? destination.label,
lat: district!.lat,
lng: district!.lng,
)
: null;
DestinationGroup copy() =>
DestinationGroup(
destination: destination.copy(),
packageCount: packageCount,
details: details.copy(),
trackingId: trackingId,
stage: stage,
index: index,
codAmount: codAmount,
)
..district = district
..verification = verification;
/// `trackingId` and `stage` are null until `order_created` — the contract
/// guarantees it, and it is what tells the UI there is no order to track yet.
factory DestinationGroup.fromJson(Map<String, dynamic> json) =>
DestinationGroup(
destination: Destination.fromJson(json),
packageCount: (json['packageCount'] as num?)?.toInt() ?? 1,
// The contract sends the recipient and address flat on the row; a
// nested `details` object is accepted too and wins where both exist.
details: DeliveryDetails.fromJson(
json['details'] as Map<String, dynamic>? ?? json,
),
trackingId: json['trackingId'] as String?,
stage: json['stage'] == null
? null
: JourneyStage.fromKey(json['stage'] as String?),
index: (json['index'] as num?)?.toInt(),
codAmount: (json['codAmount'] as num?)?.toInt(),
)
..district = json['district'] is Map<String, dynamic>
? District.fromJson(json['district'] as Map<String, dynamic>)
: null
..verification = ParcelVerification.fromJson(
json['verification'] as Map<String, dynamic>?,
);
/// One entry of `POST /customer/bookings` ▸ `destinations`.
///
/// Flat, as the contract asks: the recipient and the address sit beside the
/// codes rather than under a `details` object, and the dropped pin is spelled
/// `latitude`/`longitude`. Anything the customer left blank is omitted —
/// that is the "Not added" state the Miler completes at the door, and it is
/// not the same as sending an empty string.
Map<String, dynamic> toBookingJson() => {
...destination.toJson(),
'packageCount': packageCount,
...details.toJson(),
'codAmount': ?codAmount,
};
}
/// Indicative price for a booking, confirmed at pickup once the Miler weighs
/// and measures the parcel.
class FareEstimate {
const FareEstimate({
required this.min,
required this.max,
this.paymentMethod,
this.parcel,
this.routeKm,
});
final int min;
final int max;
/// How the customer will pay. Not in the contract's estimate response, so it
/// is usually null and the line that renders it is simply absent.
final String? paymentMethod;
/// What is being priced, in the customer's words. Null when unknown — the
/// receipt drops the row rather than claiming a "Standard box".
final String? parcel;
/// Road distance the estimate was priced over, when the server sent one.
/// `POST /customer/fare/estimate` returns it as `routeKm`.
final double? routeKm;
String get range => '₹$min – ₹$max';
/// Money is whole rupees on this surface, never paise.
///
/// The band is spelled `minRupees`/`maxRupees` by the contract; the short
/// form is still read because the booking object carries its stored estimate
/// under `fare` and has always used it.
static FareEstimate? fromJson(Map<String, dynamic>? json) {
if (json == null) return null;
final min = ((json['minRupees'] ?? json['min']) as num?)?.toInt();
final max = ((json['maxRupees'] ?? json['max']) as num?)?.toInt();
if (min == null || max == null) return null;
return FareEstimate(
min: min,
max: max,
paymentMethod: (json['paymentMethod'] as String?),
parcel: (json['parcel'] as String?),
routeKm: (json['routeKm'] as num?)?.toDouble(),
);
}
}
/// One entry in the tracking timeline.
class StageEvent {
const StageEvent(this.stage, this.at);
final JourneyStage stage;
final DateTime at;
/// Skips an entry with no usable timestamp: the timeline's whole job is to
/// say *when*, and an invented time is worse than a missing row.
static StageEvent? fromJson(Map<String, dynamic> json) {
final at = epochMillis(json['at']);
if (at == null) return null;
return StageEvent(JourneyStage.fromKey(json['stage'] as String?), at);
}
}
/// A pickup booking, which the backend converts into an order once the Miler
/// completes pickup. `trackingId` therefore stays null until [orderCreated].
class Booking {
Booking({
required this.reference,
required this.pickup,
required this.destinations,
required this.slotId,
required this.createdAt,
this.stage = JourneyStage.booked,
this.status = BookingStatus.active,
this.cancellable = true,
this.miler,
this.deliveryAgent,
this.expectedDelivery,
this.cancelReason,
this.fare,
this.amountPaid,
this.deliveredAt,
this.routeKm,
this.milersInZone = 0,
this.milerDistanceKm,
this.milerEtaMinutes,
List<StageEvent>? history,
}) : history = history ?? <StageEvent>[];
final String reference;
JourneyStage stage;
BookingStatus status;
bool cancellable;
final DateTime createdAt;
Place pickup;
/// Where the packages are going. One entry is the common case; a pickup can
/// carry several, all collected in the same Miler visit.
List<DestinationGroup> destinations;
String slotId;
// ---- convenience over [destinations] -------------------------------------
bool get isMultiDestination => destinations.length > 1;
int get totalPackages =>
destinations.fold(0, (sum, group) => sum + group.packageCount);
/// The first destination — what a single-destination booking is "about".
Destination get destination => destinations.first.destination;
/// Optional details for the first destination.
DeliveryDetails get details => destinations.first.details;
/// Only a single-destination booking has one tracking number of its own;
/// a multi-destination pickup is identified by its reference.
String? get trackingId =>
destinations.length == 1 ? destinations.first.trackingId : null;
/// True once the backend has turned the pickup into order(s).
bool get hasOrders => destinations.any((d) => d.trackingId != null);
/// Total verified weight across every destination, once weighed at pickup.
String? get totalWeightLabel {
final weighed = destinations
.where((d) => d.verification != null)
.map((d) => d.verification!.weightKg);
if (weighed.isEmpty) return null;
final total = weighed.reduce((a, b) => a + b);
return '${total.toStringAsFixed(1)} kg';
}
/// "3 packages · 2 destinations", or null when there is nothing to add up.
String? get packageSummary {
final packages = totalPackages;
if (packages <= 1 && destinations.length <= 1) return null;
final p = '$packages ${packages == 1 ? 'package' : 'packages'}';
if (destinations.length == 1) return p;
return '$p · ${destinations.length} destinations';
}
Person? miler;
Person? deliveryAgent;
String? expectedDelivery;
String? cancelReason;
/// Indicative fare shown at review and on the booking.
FareEstimate? fare;
/// What was actually charged, known once the parcel is weighed at pickup.
int? amountPaid;
/// When it reached the recipient.
DateTime? deliveredAt;
/// Pickup-to-destination distance, for the route outline. Null when the
/// backend has not measured it; the receipt drops the row rather than
/// printing "0.0 km".
double? routeKm;
/// Verified Milers active in the pickup zone, shown while assigning. Zero
/// means "the backend did not say", and the line is not shown.
int milersInZone;
/// How far the Miler is from the door, once they are on the way.
double? milerDistanceKm;
/// Minutes until the Miler reaches the door.
int? milerEtaMinutes;
List<StageEvent> history;
/// What the customer sees as the identifier for this pickup.
String get displayId => trackingId ?? reference;
bool get isCancelled => status == BookingStatus.cancelled;
DateTime? timeOf(JourneyStage s) {
for (final e in history) {
if (e.stage == s) return e.at;
}
return null;
}
/// The canonical booking object — `GET /customer/bookings/{reference}`.
///
/// Everything the tracking screen and the receipt render comes from here, so
/// this parser reads the whole shape rather than the subset the prototype
/// needed. Two rules it follows throughout:
///
/// * **Absent is not zero.** `amountPaid`, `deliveredAt`, `verification`,
/// `trackingId` and the per-destination `stage` are null before the events
/// that create them, and the UI distinguishes "not yet" from "none".
/// * **A malformed member costs its own field, not the booking.** Only
/// `reference` is required; a booking that throws is a customer who cannot
/// see their parcel at all, and staging is not proven yet.
factory Booking.fromJson(Map<String, dynamic> json) {
final rawDestinations = (json['destinations'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(DestinationGroup.fromJson)
.toList();
// `index` is what `PATCH …/destinations/{index}` addresses. The contract
// sends it; falling back to the list position keeps an older payload
// addressable rather than leaving the details sheet with nowhere to write.
for (var i = 0; i < rawDestinations.length; i++) {
rawDestinations[i].index ??= i;
}
final fare = FareEstimate.fromJson(json['fare'] as Map<String, dynamic>?);
final history =
(json['history'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(StageEvent.fromJson)
.whereType<StageEvent>()
.toList()
..sort((a, b) => a.at.compareTo(b.at));
return Booking(
reference: json['reference'] as String,
stage: JourneyStage.fromKey(json['stage'] as String?),
status: BookingStatus.fromKey(json['status'] as String?),
cancellable: json['cancellable'] as bool? ?? false,
createdAt: epochMillis(json['createdAt']) ?? DateTime.now(),
pickup: json['pickup'] is Map<String, dynamic>
? Place.fromJson(json['pickup'] as Map<String, dynamic>)
: const Place(title: 'Pickup point', sub: ''),
// A booking with no destinations cannot be rendered as a journey, but it
// can still be listed. One empty group keeps every `destinations.first`
// in the UI safe.
destinations: rawDestinations.isEmpty
? [DestinationGroup()]
: rawDestinations,
slotId: (json['slotId'] as String?) ?? '',
miler: Person.fromJson(json['miler'] as Map<String, dynamic>?),
deliveryAgent: Person.fromJson(
json['deliveryAgent'] as Map<String, dynamic>?,
),
expectedDelivery: json['expectedDelivery'] as String?,
cancelReason: json['cancelReason'] as String?,
fare: fare,
amountPaid: (json['amountPaid'] as num?)?.toInt(),
deliveredAt: epochMillis(json['deliveredAt']),
// The fare carries the distance it was priced over, so a booking that
// does not repeat `routeKm` at the top level still shows a real one.
routeKm: (json['routeKm'] as num?)?.toDouble() ?? fare?.routeKm,
milersInZone: (json['milersInZone'] as num?)?.toInt() ?? 0,
milerDistanceKm: (json['milerDistanceKm'] as num?)?.toDouble(),
milerEtaMinutes: (json['milerEtaMinutes'] as num?)?.toInt(),
history: history,
);
}
}
/// One keyset page of bookings.
///
/// Keyset, not offset: a booking landing at the top mid-scroll must not push a
/// row the customer has already seen onto the next page.
class BookingPage {
const BookingPage({
required this.bookings,
this.nextCursor,
this.total,
});
final List<Booking> bookings;
/// Cursor for the next page; null on the last one.
final String? nextCursor;
/// Rows behind the filtered tab, when the server counted them.
final int? total;
bool get hasMore => nextCursor != null && nextCursor!.isNotEmpty;
static const BookingPage empty = BookingPage(bookings: []);
}
/// A place the customer saved — `GET /customer/locations`.
class SavedPlace {
const SavedPlace({required this.id, required this.place, this.label});
final String id;
final Place place;
/// "Home", "Office" — the customer's own name for it.
final String? label;
factory SavedPlace.fromJson(Map<String, dynamic> json) => SavedPlace(
id: (json['id'] ?? '').toString(),
place: Place.fromJson(json),
label: json['label'] as String?,
);
Map<String, dynamic> toJson() => {...place.toJson(), 'label': ?label};
}
/// A row in Orders.
///
/// Before collection a booking is one pickup. Once the Miler has verified it
/// and the backend has created the orders, each destination stands on its own
/// with its own tracking number and journey — so it gets its own row, with a
/// quiet line back to the pickup they shared.
class OrderEntry {
const OrderEntry(this.booking, [this.destinationIndex]);
final Booking booking;
/// Null for the pickup itself.
final int? destinationIndex;
bool get isPickup => destinationIndex == null;
DestinationGroup? get group =>
destinationIndex == null ? null : booking.destinations[destinationIndex!];
/// True when this order shared a Miler visit with others.
bool get collectedTogether => !isPickup && booking.isMultiDestination;
/// Sort key so a split pickup keeps its rows together and in order.
DateTime get createdAt => booking.createdAt;
}
/// The signed-in customer.
class Customer {
const Customer({
required this.id,
required this.name,
required this.phone,
required this.email,
});
final String id;
final String name;
final String phone;
final String email;
String get initials {
final parts = name.trim().split(RegExp(r'\s+'));
final letters = parts.map((p) => p.isEmpty ? '' : p[0]).join();
return letters
.substring(0, letters.length < 2 ? letters.length : 2)
.toUpperCase();
}
/// `email` is typed non-nullable here and the contract promises `""` rather
/// than `null` for an unknown one — but a missing key still resolves to `""`
/// rather than throwing, because Account renders initials from `name` and
/// would lose the whole screen over a field it does not use.
factory Customer.fromJson(Map<String, dynamic> json) => Customer(
id: (json['id'] ?? '').toString(),
name: (json['name'] as String?) ?? '',
phone: (json['phone'] as String?) ?? '',
email: (json['email'] as String?) ?? '',
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'phone': phone,
'email': email,
};
}