/// 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 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. /// What `POST /customer/auth/login` answers about a phone number. /// /// ── Why the app asks before it shows a field ── /// /// PIN sign-in has three entrances and they look identical to a customer: an /// unknown number, a known number with no PIN yet, and a known number with /// one. Guessing wrong means asking somebody to "enter your PIN" when they /// have never set one, or asking a returning customer to invent a new one over /// the top of theirs (which the server refuses with `pin_already_set`, leaving /// them stuck on a screen that cannot succeed). /// /// One call up front removes the guess. The screen that follows is the right /// one every time. class PhoneCheck { const PhoneCheck({ required this.phone, required this.registered, required this.pinSet, this.name, }); /// The number as the server normalised it — E.164. Sent back on the next /// call rather than re-normalised here, so both requests agree. final String phone; final bool registered; final bool pinSet; /// The account holder's first name, when there is an account. /// /// The server returns it and the backend has been asked to stop, because it /// discloses who owns a number to anybody who types it. The app therefore /// **does not display it** — it is read only so that it disappears quietly /// when the backend drops the field, rather than becoming a missing greeting /// somebody has to go and diagnose. final String? name; /// Which screen comes next. PhoneStep get step => !registered ? PhoneStep.createAccount : (pinSet ? PhoneStep.enterPin : PhoneStep.createPin); factory PhoneCheck.fromJson(Map json, String fallback) => PhoneCheck( phone: (json['phone'] as String?)?.trim().isNotEmpty == true ? (json['phone'] as String).trim() : fallback, // snake_case here and nowhere else on the customer surface — the PIN // routes were added separately and spell it `pin_set`. Both are read // so a later tidy-up on the server does not break sign-in. registered: (json['registered'] ?? json['isRegistered']) == true, pinSet: (json['pin_set'] ?? json['pinSet']) == true, name: (json['name'] as String?)?.trim(), ); } /// The screen a phone number leads to. enum PhoneStep { /// No account: ask for a name and a new PIN. createAccount, /// Account exists, no PIN: ask for a new PIN only. createPin, /// Account with a PIN: ask for it. enterPin, } 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 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 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 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 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 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 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. } /// 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 = [?_clean(districtName), ?_clean(stateName)]; if (named.isNotEmpty) return named.join(', '); final coded = [?_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 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 toJson() => { 'stateCode': ?stateCode, 'districtCode': ?districtCode, }; } class MapPin { const MapPin(this.lat, this.lng); final double lat; final double lng; static MapPin? fromJson(Map? 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); } /// `{lat, lng}` — the contract's spelling, and the one that has already /// cost this app a production outage once: `latitude`/`longitude` on a /// pickup made the server see a booking with no coordinates and answer /// **422 unserviceable** for months. The destination pin was left on the old /// spelling when the pickup was fixed. Map toJson() => {'lat': lat, 'lng': 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 = [ ?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 = [?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? 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?) ?? 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 contents of a destination's `details` object on booking create. /// /// ── This used to be spread flat onto the destination ── /// /// It read well and it was silently discarded. The create contract nests /// these under `details`, and extra keys on a destination are dropped /// without an error — so every building number, street, landmark, recipient /// and pin a customer typed on the full-address path went to the server, /// was accepted with a 201, and never reached the Miler. /// /// [instructions] belongs here too. It was being folded into the visit's one /// `remarks` line on the belief that the contract had no per-destination /// note. It has one. Map toJson() => { 'recipientName': ?recipientName, 'recipientPhone': ?recipientPhone, 'building': ?building, 'street': ?street, 'landmark': ?landmark, 'instructions': ?instructions, 'pin': ?pin?.toJson(), }; /// `PATCH /customer/bookings/{reference}/destinations/{index}`. /// /// The body *is* the details object, so this one is flat by design — unlike /// create, where it nests. /// /// ── Empty string clears; null does not ── /// /// This sent `null` to clear a field, and said so in a comment. The server /// writes only non-nil values, so a `null` means "leave it alone" — which /// made removing a landmark or an instruction impossible. Every key is still /// sent, but an unset field goes as `""`, and an unset pin as `{0,0}`, which /// is what the contract documents as clearing them. Map toPatchJson() => { 'recipientName': recipientName ?? '', 'recipientPhone': recipientPhone ?? '', 'building': building ?? '', 'street': street ?? '', 'landmark': landmark ?? '', 'instructions': instructions ?? '', 'pin': pin?.toJson() ?? const {'lat': 0, 'lng': 0}, }; } /// 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 = [ 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? 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 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 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? 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() .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 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? ?? 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 ? District.fromJson(json['district'] as Map) : null ..verification = ParcelVerification.fromJson( json['verification'] as Map?, ); /// 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 toBookingJson() { final detail = {...details.toJson(), 'codAmount': ?codAmount}; return { ...destination.toJson(), 'packageCount': packageCount, // Omitted rather than sent empty: One Touch fills none of this in, and // `details: {}` is a key that says nothing. if (detail.isNotEmpty) 'details': detail, }; } } /// 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? 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 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? history, }) : history = history ?? []; 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 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 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 json) { final rawDestinations = (json['destinations'] as List? ?? const []) .whereType>() .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?); final history = (json['history'] as List? ?? const []) .whereType>() .map(StageEvent.fromJson) .whereType() .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 ? Place.fromJson(json['pickup'] as Map) : 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?), deliveryAgent: Person.fromJson( json['deliveryAgent'] as Map?, ), 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 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 json) => SavedPlace( id: (json['id'] ?? '').toString(), place: Place.fromJson(json), label: json['label'] as String?, ); Map 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 json) => Customer( id: (json['id'] ?? '').toString(), name: (json['name'] as String?) ?? '', phone: (json['phone'] as String?) ?? '', email: (json['email'] as String?) ?? '', ); Map toJson() => { 'id': id, 'name': name, 'phone': phone, 'email': email, }; }