── Navigation ── Pages arrived from the right and left to the right. Right is the direction the system back gesture drags, so a page leaving under a button press read as a swipe nobody made — and every push in this app is a layer over the one before it, which arrives from below. The axis is vertical now: the incoming page rises and covers, the page underneath stays put and dims, and back drops the top page off the bottom. Full travel rather than the old 14% nudge, which is what makes the direction nameable. test/page_transition_test.dart holds it. ── Home ── The pickup pill is back under the greeting. It was cut on the argument that the form below already carried the pickup; the form is four hundred points down the screen, and a location you can only see by scrolling to a form is a location you do not trust. It shows the locality while the form keeps the door, so the screen answers one question once, and it now has a resolving state — it used to print the last place you were, confidently, while the fix was still in flight. The sphere's caption was falling off the screen. Adding the pill pushed it out of the scroll viewport, silently, with no overflow warning — the fourth time Home growing has clipped something under the sphere. DmBookOrb.field is no longer a constant: the glow gives way to whatever is left, the sphere never does. ── 74 points of dead canvas, on every root ── Home, Orders and Account each cleared the floating tab bar by `MediaQuery.paddingOf(context).bottom + 74`, on the reasoning that the inset is the home indicator and 74 is the bar above it. Scaffold with extendBody replaces the body's bottom padding with the bar's whole laid-out height — measured, a 34pt inset and this bar hand the body 106. They were clearing the bar, then clearing it again. One helper now, dmTabBarClear(). ── One journey, one vocabulary ── Review said ORIGIN/DESTINATION; Home's form and tracking say PICKUP/DROP. Three screens a customer walks in order. The tracking card had already written down the argument; the booking screen hadn't followed. The rail's first milestone read "Booking created" directly above "Order created" — down the rail: created, created, in transit. It is "Pickup booked", which is what JourneyStage.milestoneLabel has called that stage all along on Orders and on Home's live row. ── The launcher icon ── tool/icons.py renders all twenty-eight files from one master, because the failure mode of exporting by hand is nineteen replaced and nine left on the old mark, at the density nobody checks. The master is a bare mark on a field, so it cannot be dropped into Android's adaptive canvas whole: crimson is the background, the white mark is the foreground, and it is sized by its reach from the canvas centre so a round mask cannot take the arrow tip. Measured on the built file: 63.7dp against a 66dp safe zone. A real monochrome layer replaces the colour foreground that was standing in for one — the system tints by alpha, so that themed as a blob. ic_launcher_round.png exists at last; minSdk is 24 and android:roundIcon had nothing to resolve to below API 26. The artwork is flattened (the source vignettes over ten values of red) and its horizontal offset is placed deliberately rather than inherited — geometric centring made the mark lean, because the arrow carries the bounding box right while contributing almost none of the ink. ── Bundle ── pubspec declared assets/images/ as a folder, which shipped the 974 KB icon master to every customer for a file no code opens. Named explicitly now.
1339 lines
46 KiB
Dart
1339 lines
46 KiB
Dart
/// 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.
|
||
///
|
||
/// Deliberately **not** [CustomerMilestone.of]. The rail collapses the whole
|
||
/// pickup visit into "Pickup booked", which is right for a journey read
|
||
/// over days — but a pill on Orders, or the live row on Home, is answering
|
||
/// "what is happening to my parcel *now*", and "Pickup booked" is a poor
|
||
/// answer while a named Miler is two kilometres from the door. The two
|
||
/// surfaces want different granularity from the same stage, so they get
|
||
/// their own vocabulary.
|
||
String get milestoneLabel => switch (this) {
|
||
JourneyStage.booked => 'Pickup booked',
|
||
JourneyStage.assigned => 'Miler assigned',
|
||
JourneyStage.onTheWay => 'Miler on the way',
|
||
JourneyStage.arrived => 'Miler at your door',
|
||
JourneyStage.pickedUp => 'Package collected',
|
||
JourneyStage.orderCreated => 'Order created',
|
||
JourneyStage.inTransit => 'In transit',
|
||
JourneyStage.outForDelivery => 'Out for delivery',
|
||
JourneyStage.delivered => 'Delivered',
|
||
};
|
||
|
||
/// The same state in one or two words, for a status chip.
|
||
///
|
||
/// [milestoneLabel] is a sentence fragment written to sit under a heading —
|
||
/// "Miler assigned", "Miler at your door". Set in a 9.5pt chip beside a
|
||
/// reference and a time it renders as "MILER ASSIG…", which is not a state.
|
||
String get chipLabel => switch (this) {
|
||
JourneyStage.booked => 'Booked',
|
||
JourneyStage.assigned => 'Assigned',
|
||
JourneyStage.onTheWay => 'On the way',
|
||
JourneyStage.arrived => 'At your door',
|
||
JourneyStage.pickedUp => 'Collected',
|
||
JourneyStage.orderCreated => 'Created',
|
||
JourneyStage.inTransit => 'In transit',
|
||
JourneyStage.outForDelivery => 'Out for delivery',
|
||
JourneyStage.delivered => 'Delivered',
|
||
};
|
||
}
|
||
|
||
/// What the customer sees on the timeline.
|
||
///
|
||
/// The backend keeps nine operational states; the rail shows five. Operational detail ("Miler on the way", "Order created")
|
||
/// appears as context under the current milestone rather than as another
|
||
/// permanent row.
|
||
/// The shipment's journey, as five milestones.
|
||
///
|
||
/// ── Why the pickup is one milestone and not four ──
|
||
///
|
||
/// This had seven: booked, assigned, pickup in progress, collected, in
|
||
/// transit, out for delivery, delivered. Four of them described the *visit* —
|
||
/// a Miler being found, riding over, arriving, weighing — which is an hour of
|
||
/// somebody's afternoon, and the tracking screen already narrates all of it
|
||
/// live in the crimson card at the top, by name, with a distance and an ETA.
|
||
/// The rail was repeating that hour in four rows and then giving the whole
|
||
/// rest of the journey — the part measured in days — the other three.
|
||
///
|
||
/// The rail's job is the shipment. Everything before the order exists is
|
||
/// **Pickup booked**; the moment the Miler creates the order the parcel is a
|
||
/// consignment with a tracking number, and the four milestones after that are
|
||
/// the ones a customer checks back for over the following days.
|
||
///
|
||
/// ── Why the first one is not "Booking created" ──
|
||
///
|
||
/// It was, and it sat directly above "Order created". Two rows, one word
|
||
/// apart, both past-tense creations of something the customer cannot
|
||
/// distinguish — a booking and an order are the same parcel to them, and the
|
||
/// rail's whole job is to show movement. Read down the rail it said: created,
|
||
/// created, in transit.
|
||
///
|
||
/// "Pickup booked" is not a new coinage either. [JourneyStage.milestoneLabel]
|
||
/// has called this stage exactly that all along, on Orders and on Home's live
|
||
/// row, so the rail was the one surface using a different word for the state
|
||
/// the rest of the app had already named.
|
||
enum CustomerMilestone {
|
||
booked('Pickup booked'),
|
||
orderCreated('Order created'),
|
||
inTransit('In transit'),
|
||
outForDelivery('Out for delivery'),
|
||
delivered('Delivered');
|
||
|
||
const CustomerMilestone(this.label);
|
||
|
||
final String label;
|
||
|
||
/// Which milestone an internal stage rolls up into.
|
||
///
|
||
/// Everything up to and including the handover is the booking: the customer
|
||
/// has arranged a visit, and until the order exists there is nothing else to
|
||
/// call it.
|
||
static CustomerMilestone of(JourneyStage stage) => switch (stage) {
|
||
JourneyStage.booked ||
|
||
JourneyStage.assigned ||
|
||
JourneyStage.onTheWay ||
|
||
JourneyStage.arrived ||
|
||
JourneyStage.pickedUp => CustomerMilestone.booked,
|
||
JourneyStage.orderCreated => CustomerMilestone.orderCreated,
|
||
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'),
|
||
);
|
||
|
||
/// ── The coordinates are `lat`/`lng`, NOT `latitude`/`longitude` ──
|
||
///
|
||
/// The written contract says `latitude`/`longitude` and this sent them for
|
||
/// months. The server reads `lat`/`lng`, so it saw a pickup with no
|
||
/// coordinates at all, could not place it in a serviceable area, and
|
||
/// answered every single booking with **422 `unserviceable` — "We are not
|
||
/// collecting from that area yet"**. The app could not create a booking
|
||
/// against production; the error blamed the customer's address for a field
|
||
/// name.
|
||
///
|
||
/// Verified against production on 2026-09-23, one request apart: the same
|
||
/// pickup, the same destination, the same slot. `latitude`/`longitude` →
|
||
/// 422 unserviceable. `lat`/`lng` → 201 and booking DM-252803. It is also
|
||
/// what `GET /places/reverse-geocode` returns, which is where this Place
|
||
/// came from in the first place — the app was renaming the server's own
|
||
/// keys on the way back out.
|
||
///
|
||
/// Second one of these on this surface: `POST /auth/otp/verify` takes
|
||
/// `code` where the document says `otp`. Follow the server, not the
|
||
/// document.
|
||
Map<String, dynamic> toJson() => {
|
||
'title': title,
|
||
'sub': sub,
|
||
'lat': ?lat,
|
||
'lng': ?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(', ');
|
||
}
|
||
|
||
/// The district alone, for a headline. Falls back to the full [label] when
|
||
/// the backend sent only a state or only codes — a card headed "—" is worse
|
||
/// than a card headed with two words.
|
||
String get shortLabel => _clean(districtName) ?? _clean(districtCode) ?? label;
|
||
|
||
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,
|
||
};
|
||
}
|