Four passes, and the shape they landed on. The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a fix with it: it carries the rupee glyph and Switzer does not, so prices stop being set in Geist Mono to work around a missing character. Mono stays where it is earned - references and phone numbers, read digit by digit. Surfaces lift rather than outline. Cards carry two very soft shadow layers instead of a hairline, because eight outlined boxes down a screen read as a wireframe. The tab bar floats as a pill again for the same reason it was right to: it is now the same kind of object as everything above it. Screens: * Home is the greeting, the address, the sphere and one card. The card lost its progress bar - a filling line says "wait", and a parcel two days into a journey is not something anyone is waiting through - and gained the size that buys. * Orders cards are four bands: identity, destination, route, and whatever is happening right now. Plus a search field, because the list is the archive. * Tracking leads with the state at display size, then TRIP MILESTONES with a step counter, then the courier. * Review is a route thread over two particular cards. * Account opens on the person: avatar, name, and two counted figures. Three real bugs the redesign surfaced: * Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the catalogue was refetched on every rebuild and Home never settled. * Order cards showed the whole visit's weight on one destination's row - somebody else's parcel. Per group now, and only once actually weighed. * The pickup window was printed beside "In transit", where it reads as a delivery time nobody promised. Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET, CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so they are absent rather than mocked up. flutter analyze: clean. flutter test: 88 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
1326 lines
45 KiB
Dart
1326 lines
45 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 "Booking created", 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 "Booking created" 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
|
||
/// **Booking created**; 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.
|
||
enum CustomerMilestone {
|
||
booked('Booking created'),
|
||
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,
|
||
};
|
||
}
|