Two ways to book the same parcel, and the difference said where the choice is made rather than in a paragraph about it. ONE TOUCH is the sphere: a state, a district, a window, and nothing else. The door, the recipient and the weight go out omitted, which the contract already treats as "not added yet" - the Miler writes them down standing at the address with the parcel in hand, which is the only moment anybody knows them. The pick/drop form under it is the other way in. PICKUP already carries the detected address; DROP is the only row with a question on it, and it asks for the full door - once per destination - before the window. Captions rather than an explainer: "Just a city and a time" under the sphere, "Enter the full address yourself" on the row. The pickup screen is a map screen now instead of a screen with a map on it. Full-bleed basemap, pin nailed to the centre, floating search and back, and a fixed sheet carrying what the pin resolved to. The collect-from sheet can hand off to it and carry the pin back. Also: * Home reads what-is-running, then the one gesture, then the form. The live card lost its courier row and half its height. * The sphere turns inside out on press - white floods from the centre and the word inverts through the clip, not by tween - and bounces back on release. * Order IDs copy, on Orders and on tracking. * Tracking says PICKUP and DROP with an arrow between them, carries the reference at the foot of the card it identifies, and shows every timing exactly once. * The wordmark bar is gone from all three roots; Orders and Account had no SafeArea under it, so their titles were sitting under the notch. flutter analyze: clean. flutter test: 90 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
856 lines
30 KiB
Dart
856 lines
30 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../data/app_config.dart';
|
|
import '../data/doormile_api.dart';
|
|
import '../data/location_service.dart';
|
|
import '../data/models.dart';
|
|
|
|
/// Single source of truth for the customer app.
|
|
///
|
|
/// Screens read from here and call these methods; nothing mutates a [Booking]
|
|
/// directly.
|
|
///
|
|
/// **Every booking on screen came from the server.** This class has no way to
|
|
/// invent one and no way to move one along: a stage changes when the backend
|
|
/// says it changed and [refreshBooking] reads it back.
|
|
class AppState extends ChangeNotifier {
|
|
AppState({DoormileApi? api, LocationService? location})
|
|
: api = api ?? DoormileApi.instance,
|
|
location = location ?? LocationService.instance {
|
|
this.api.onSessionLost = onSessionLost;
|
|
loadLimits();
|
|
}
|
|
|
|
final DoormileApi api;
|
|
final LocationService location;
|
|
|
|
// ------------------------------------------------------------- session
|
|
|
|
Customer? customer;
|
|
|
|
/// **The authoritative pickup point.** Display address *and* the coordinates
|
|
/// the Miler is sent to, in one object.
|
|
///
|
|
/// All three ways of choosing a pickup — the device fix, a map drag, and a
|
|
/// search result — write this single field, so nothing downstream has to
|
|
/// reconcile "the address" against "the pin".
|
|
Place? pickup;
|
|
|
|
/// True once we have both an address and coordinates to send a Miler to.
|
|
/// The booking step gates on this rather than on the address alone.
|
|
bool get hasPickupPoint => pickup?.hasLocation ?? false;
|
|
|
|
/// True while a pin is being turned into an address. The pickup card shows a
|
|
/// placeholder rather than a stale street name.
|
|
bool resolvingPickup = false;
|
|
|
|
/// Why we could not read the device location, if we could not. Null when the
|
|
/// pin came from GPS or from the customer.
|
|
LocationDenial? locationDenial;
|
|
|
|
/// Where the **customer** is, as opposed to where the pickup pin is.
|
|
///
|
|
/// Kept separate on purpose: the pin moves when they drag the map, this does
|
|
/// not. It is what the blue dot renders at, and it survives a drag so they
|
|
/// can always see how far they have moved the pin from themselves.
|
|
double? deviceLat;
|
|
double? deviceLng;
|
|
|
|
/// Horizontal accuracy in metres, when the platform reported it — the halo
|
|
/// around the dot.
|
|
double? deviceAccuracy;
|
|
|
|
bool get hasDeviceLocation => deviceLat != null && deviceLng != null;
|
|
|
|
/// Guards against an earlier reverse-geocode landing after a later drag.
|
|
int _pinSeq = 0;
|
|
|
|
/// Where the map should open: the current pin, else the service-area default.
|
|
double get mapLat => pickup?.lat ?? LocationService.fallbackLat;
|
|
double get mapLng => pickup?.lng ?? LocationService.fallbackLng;
|
|
|
|
bool get isSignedIn => customer != null;
|
|
|
|
/// True until the stored session has been looked for. The app shows its
|
|
/// launch state rather than the sign-in screen while this is set, so a
|
|
/// returning customer never sees a login form flash past.
|
|
bool restoringSession = true;
|
|
|
|
void signIn(Customer c) {
|
|
customer = c;
|
|
notifyListeners();
|
|
unawaited(refreshOrders());
|
|
}
|
|
|
|
/// Looks for a persisted session at launch.
|
|
///
|
|
/// Never throws: being offline at launch is not being signed out, and the
|
|
/// stored customer stands until a call actually comes back 401.
|
|
Future<void> restoreSession() async {
|
|
try {
|
|
final restored = await api.restoreSession();
|
|
if (restored != null) {
|
|
customer = restored;
|
|
_afterSignIn();
|
|
} else if (AppConfig.devAutoLogin) {
|
|
// ── Offline builds open on Home, not on a login screen ──
|
|
//
|
|
// The fake signs anybody in — there is no server to refuse them — so
|
|
// stopping at the entrance would ask for a phone number and a code
|
|
// that mean nothing, purely to arrive somewhere the build was always
|
|
// going to allow. Any 4-digit code still works if someone wants to walk
|
|
// the screen; this is the default rather than the only way through.
|
|
//
|
|
// Real builds are untouched: `useDevData` is false in every one of
|
|
// them, so the login screen a customer meets is the real one.
|
|
customer = await api.verifyOtp('+91 9876543210', '1234');
|
|
_afterSignIn();
|
|
} else if (AppConfig.hasAutoLogin) {
|
|
await _autoSignIn();
|
|
}
|
|
} catch (e) {
|
|
// Same rule as the limits call: this decides which screen opens, so it
|
|
// fails to "signed out", never to a crash.
|
|
debugPrint('[SESSION] restore failed: $e');
|
|
} finally {
|
|
restoringSession = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Signs in for real, without the login screen — `DM_LOGIN_AS`.
|
|
///
|
|
/// This is a genuine sign-in, not a bypass: it runs the same two calls the
|
|
/// login and code screens run, and what it ends up holding is a token the
|
|
/// server issued. That is the whole point of it existing next to
|
|
/// the deleted offline mock — a build that skips the entrance still has to
|
|
/// end up as a real customer, or every booking it makes is a fake one.
|
|
///
|
|
/// Failure is not fatal and never throws: the app simply lands on the login
|
|
/// screen, which is exactly where someone whose code was refused should be.
|
|
Future<void> _autoSignIn() async {
|
|
final identifier = AppConfig.autoLoginIdentifier;
|
|
final code = AppConfig.autoLoginCode;
|
|
|
|
// ── Verify first, request second ──
|
|
//
|
|
// Requesting a code is not free: with no SMS gateway registered the server
|
|
// generates a fresh one, writes it to its log and REPLACES whatever was in
|
|
// Redis. So a code someone had just read out of that log would be
|
|
// invalidated by the very call meant to use it.
|
|
//
|
|
// So this tries the code it was given first. That path is the one that
|
|
// works with a log-read code, and with a fixed `CX_STAGING_OTP` it simply
|
|
// fails once on a cold Redis and falls through to the request below.
|
|
try {
|
|
customer = await api.verifyOtp(identifier, code);
|
|
_afterSignIn();
|
|
debugPrint('[AUTH] DM_LOGIN_AS — signed in as $identifier');
|
|
return;
|
|
} catch (_) {
|
|
// Expected when no code has been issued for this identifier yet.
|
|
}
|
|
|
|
try {
|
|
await api.sendOtp(identifier);
|
|
customer = await api.verifyOtp(identifier, code);
|
|
_afterSignIn();
|
|
debugPrint('[AUTH] DM_LOGIN_AS — signed in as $identifier after a request');
|
|
} catch (e) {
|
|
debugPrint(
|
|
'[AUTH] DM_LOGIN_AS failed for $identifier: $e — '
|
|
'the code was refused, so the login screen opens instead',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The two things every signed-in session needs, whichever door it came in
|
|
/// through.
|
|
///
|
|
/// Finding the customer's own location was the OTP screen's job, which meant
|
|
/// it only happened on the one path that shows an OTP screen. A restored
|
|
/// session, a dev auto-login and `DM_LOGIN_AS` all skipped it — so the app
|
|
/// opened on Home reading "Set a pickup address", never asked for the
|
|
/// location permission, and the pickup map had nothing to centre on. It is
|
|
/// not the entrance's job; it is the session's.
|
|
void _afterSignIn() {
|
|
unawaited(refreshOrders());
|
|
unawaited(detectPickupLocation());
|
|
}
|
|
|
|
/// Called by the API layer when a refresh fails — the chain is dead, so the
|
|
/// customer is signed out wherever they happen to be standing.
|
|
void onSessionLost() {
|
|
if (customer == null) return;
|
|
debugPrint('[SESSION] lost, signing out');
|
|
signOut();
|
|
}
|
|
|
|
Future<void> signOut() async {
|
|
customer = null;
|
|
orders.clear();
|
|
pickup = null;
|
|
deviceLat = null;
|
|
deviceLng = null;
|
|
deviceAccuracy = null;
|
|
locationDenial = null;
|
|
ordersCursor = null;
|
|
clearCaches();
|
|
notifyListeners();
|
|
try {
|
|
await api.signOut();
|
|
} on ApiException catch (e) {
|
|
// Local sign-out has already happened and must stand.
|
|
debugPrint('[SESSION] sign-out call failed: $e');
|
|
}
|
|
}
|
|
|
|
/// Reads the device location and turns it into an address.
|
|
///
|
|
/// Never throws and never leaves the customer stuck: a refused permission
|
|
/// falls back to the service-area centre with [locationDenial] set, so the
|
|
/// screen can explain it and offer search instead.
|
|
Future<void> detectPickupLocation() async {
|
|
final seq = ++_pinSeq;
|
|
resolvingPickup = true;
|
|
notifyListeners();
|
|
|
|
final fix = await location.current();
|
|
if (seq != _pinSeq) return;
|
|
locationDenial = fix.denial;
|
|
|
|
if (fix.hasFix) {
|
|
deviceLat = fix.lat;
|
|
deviceLng = fix.lng;
|
|
deviceAccuracy = fix.accuracy;
|
|
}
|
|
|
|
final lat = fix.lat ?? pickup?.lat ?? LocationService.fallbackLat;
|
|
final lng = fix.lng ?? pickup?.lng ?? LocationService.fallbackLng;
|
|
await _resolvePin(lat, lng, seq);
|
|
}
|
|
|
|
/// Called when the map settles under the pin. The newest drag always wins.
|
|
Future<void> setPickupFromPin(double lat, double lng) async {
|
|
final seq = ++_pinSeq;
|
|
resolvingPickup = true;
|
|
notifyListeners();
|
|
await _resolvePin(lat, lng, seq);
|
|
}
|
|
|
|
Future<void> _resolvePin(double lat, double lng, int seq) async {
|
|
Place resolved;
|
|
try {
|
|
resolved = await api.reverseGeocode(lat: lat, lng: lng);
|
|
} on ApiException {
|
|
resolved = Place(
|
|
title: 'Pinned location',
|
|
sub: 'Address unavailable — the Miler will confirm it at pickup',
|
|
lat: lat,
|
|
lng: lng,
|
|
);
|
|
}
|
|
if (seq != _pinSeq) return;
|
|
pickup = resolved;
|
|
resolvingPickup = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Adopts a place chosen from search. Cancels any in-flight pin resolution,
|
|
/// so the newest customer intent always wins — same rule as a drag.
|
|
void setPickup(Place place) {
|
|
_pinSeq++;
|
|
// A search result without coordinates cannot be a pin. The backend
|
|
// contract requires them (§6.2); if one arrives without, keep the address
|
|
// and hold the previous pin rather than dropping the customer somewhere
|
|
// else on the map.
|
|
assert(
|
|
place.hasLocation,
|
|
'searchPlaces returned a Place with no coordinates: ${place.title}',
|
|
);
|
|
pickup = place.hasLocation
|
|
? place
|
|
: Place(
|
|
title: place.title,
|
|
sub: place.sub,
|
|
lat: pickup?.lat,
|
|
lng: pickup?.lng,
|
|
);
|
|
resolvingPickup = false;
|
|
locationDenial = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Opens OS settings after a permanent refusal.
|
|
Future<void> openLocationSettings() => location.openSettings();
|
|
|
|
// ------------------------------------------------- service delegation
|
|
//
|
|
// Screens call these, never `DoormileApi.instance`. That keeps the injected
|
|
// instance authoritative and means no widget can tell whether an answer came
|
|
// from the backend.
|
|
|
|
Future<OtpChallenge> sendOtp(String identifier) => api.sendOtp(identifier);
|
|
|
|
Future<OtpChallenge> signUp({
|
|
required String name,
|
|
required String phone,
|
|
String? email,
|
|
}) => api.signUp(name: name, phone: phone, email: email);
|
|
|
|
/// Verifies the code and adopts the session in one step, so no screen has to
|
|
/// remember to call [signIn] afterwards.
|
|
Future<Customer> verifyOtp(
|
|
String identifier,
|
|
String code, {
|
|
String? name,
|
|
}) async {
|
|
final verified = await api.verifyOtp(identifier, code, name: name);
|
|
signIn(verified);
|
|
return verified;
|
|
}
|
|
|
|
/// Biased towards the pickup point the customer is working with, so a search
|
|
/// for a street name answers with the one in their city first.
|
|
Future<List<Place>> searchPlaces(String query) {
|
|
final near = draftPickup ?? pickup;
|
|
return api.searchPlaces(query, lat: near?.lat, lng: near?.lng);
|
|
}
|
|
|
|
/// Backend policy on cancellation; the UI only asks.
|
|
bool isCancellable(JourneyStage stage) => api.isCancellable(stage);
|
|
|
|
/// The window a booking was placed in.
|
|
///
|
|
/// Served from whatever [loadSlots] last returned, which is why the index is
|
|
/// never cleared: a booking made an hour ago must still be able to name its
|
|
/// window after the slot list has rolled over.
|
|
PickupSlot? slotById(String? id) =>
|
|
id == null ? null : _slotIndex[id] ?? api.slotById(id);
|
|
|
|
final Map<String, PickupSlot> _slotIndex = {};
|
|
|
|
// --------------------------------------------------------------- limits
|
|
|
|
/// Caps on one pickup. Backend-owned; the UI never hardcodes them.
|
|
BookingLimits limits = const BookingLimits();
|
|
|
|
/// Caps are allowed to vary by city, so the pickup point goes with the ask.
|
|
Future<void> loadLimits() async {
|
|
try {
|
|
limits = await api.getBookingLimits(pickup: pickup);
|
|
notifyListeners();
|
|
} catch (e) {
|
|
// The safe cap is already in place; a failed fetch must not block
|
|
// booking. Catching everything, not just ApiException: this runs from
|
|
// the constructor, and a malformed response is not worth a dead app.
|
|
debugPrint('[LIMITS] keeping defaults: $e');
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------- booking draft
|
|
|
|
/// The in-progress pickup. One destination is the common case; the customer
|
|
/// can add more, all collected in the same visit.
|
|
///
|
|
/// A setter rather than a field, because the map editor writes it and then
|
|
/// pops: as a plain field the send screen underneath kept its last frame and
|
|
/// went on showing the address the customer had just corrected.
|
|
Place? get draftPickup => _draftPickup;
|
|
set draftPickup(Place? place) {
|
|
if (_draftPickup == place) return;
|
|
_draftPickup = place;
|
|
notifyListeners();
|
|
}
|
|
|
|
Place? _draftPickup;
|
|
|
|
List<DestinationGroup> draftDestinations = [DestinationGroup()];
|
|
String? draftSlotId;
|
|
FareEstimate? draftFare;
|
|
|
|
int get draftTotalPackages =>
|
|
draftDestinations.fold(0, (sum, d) => sum + d.packageCount);
|
|
|
|
bool get canAddDestination =>
|
|
draftDestinations.length < limits.maxDestinations &&
|
|
draftTotalPackages < limits.maxPackages &&
|
|
draftDestinations.every((d) => d.isComplete);
|
|
|
|
bool canAddPackageTo(int index) => draftTotalPackages < limits.maxPackages;
|
|
|
|
/// Every destination has a serviceable state and district.
|
|
bool get draftReady =>
|
|
draftDestinations.isNotEmpty &&
|
|
draftDestinations.every((d) => d.isComplete);
|
|
|
|
/// One key per booking *intent*, held across every retry of it.
|
|
///
|
|
/// A retried `POST /customer/bookings` carrying the same key replays the
|
|
/// original response instead of creating a second pickup; a new key is a new
|
|
/// booking. So it is minted once here, when the customer starts filling the
|
|
/// form, and cleared only when a booking actually lands.
|
|
String? _bookingIdempotencyKey;
|
|
|
|
/// Whether this booking asks the customer for the drop address.
|
|
///
|
|
/// ── Two ways to book the same parcel ──
|
|
///
|
|
/// **One Touch** asks three things: the state, the district and a pickup
|
|
/// window. Nothing else — no door number, no recipient, no weight. Those are
|
|
/// filled in by the Miler standing at the address with the parcel in their
|
|
/// hand, which is the only moment anybody actually knows them.
|
|
///
|
|
/// **The full form** is for the customer who already knows the door and
|
|
/// would rather type it than have it asked for later. It is the same
|
|
/// booking with `details` populated on the wire.
|
|
///
|
|
/// The contract has always taken both: [DestinationGroup.toBookingJson]
|
|
/// omits whatever is blank, and an omitted field is precisely the "not added
|
|
/// yet" state the Miler completes. This flag only decides what the app asks.
|
|
bool draftDetailed = false;
|
|
|
|
void startBooking({bool detailed = false}) {
|
|
draftDetailed = detailed;
|
|
draftPickup = pickup;
|
|
draftDestinations = [DestinationGroup()];
|
|
draftSlotId = null;
|
|
draftFare = null;
|
|
districtCache.clear();
|
|
// Slots carry their own date. A session left open across midnight would
|
|
// otherwise offer yesterday's windows, and booking one is refused — so the
|
|
// list is re-read for every booking rather than kept for the session.
|
|
_invalidateSlots();
|
|
_bookingIdempotencyKey = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Whether "Send to another place" is offered at all.
|
|
///
|
|
/// Multi-destination is server-gated while the Miler app still keys its work
|
|
/// on a booking-level id: a fanned-out pickup would give the rider one stop
|
|
/// for three parcels. The backend advertises the cap, the client obeys it,
|
|
/// and a cap of 1 hides the control rather than showing a dead one.
|
|
bool get allowsMultipleDestinations => limits.allowsMultipleDestinations;
|
|
|
|
void selectStateFor(int index, ServiceArea area) {
|
|
draftDestinations[index].destination
|
|
..stateCode = area.code
|
|
..stateName = area.name
|
|
..districtCode = null
|
|
..districtName = null;
|
|
draftDestinations[index].district = null;
|
|
draftFare = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
void selectDistrictFor(int index, District district) {
|
|
draftDestinations[index]
|
|
..destination.districtCode = district.code
|
|
..destination.districtName = district.name
|
|
..district = district;
|
|
notifyListeners();
|
|
_loadFare();
|
|
}
|
|
|
|
void setPackageCount(int index, int count) {
|
|
final group = draftDestinations[index];
|
|
final others = draftTotalPackages - group.packageCount;
|
|
group.packageCount = count.clamp(1, limits.maxPackages - others);
|
|
notifyListeners();
|
|
_loadFare();
|
|
}
|
|
|
|
/// Returns false when the caps say no, so the caller can explain why.
|
|
bool addDestination() {
|
|
if (!canAddDestination) return false;
|
|
draftDestinations.add(DestinationGroup());
|
|
notifyListeners();
|
|
return true;
|
|
}
|
|
|
|
void removeDestination(int index) {
|
|
if (draftDestinations.length <= 1) return;
|
|
draftDestinations.removeAt(index);
|
|
notifyListeners();
|
|
_loadFare();
|
|
}
|
|
|
|
/// Republish after something mutated the draft in place (the details sheet).
|
|
void touch() => notifyListeners();
|
|
|
|
void selectSlot(PickupSlot slot) {
|
|
draftSlotId = slot.id;
|
|
notifyListeners();
|
|
}
|
|
|
|
void setDraftPin(int index, MapPin? pin) {
|
|
draftDestinations[index].details.pin = pin;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Priced as soon as the route is known, so Review has it ready.
|
|
Future<void> _loadFare() async {
|
|
final from = draftPickup ?? pickup;
|
|
if (from == null || !draftReady) return;
|
|
try {
|
|
draftFare = await api.estimateFare(
|
|
pickup: from,
|
|
destinations: draftDestinations,
|
|
);
|
|
notifyListeners();
|
|
} on ApiException {
|
|
// A missing estimate is not worth blocking the booking over.
|
|
}
|
|
}
|
|
|
|
/// Who the Miler asks for at the pickup door.
|
|
///
|
|
/// Null means the signed-in customer, which is the answer nearly every time
|
|
/// — so the field on the pickup screen opens prefilled with their number and
|
|
/// this stays null until they change it.
|
|
String? draftContactPhone;
|
|
|
|
Future<Booking> confirmBooking() async {
|
|
final key = _bookingIdempotencyKey ??= _newIdempotencyKey();
|
|
try {
|
|
final booking = await api.createBooking(
|
|
pickup: draftPickup ?? pickup!,
|
|
destinations: draftDestinations,
|
|
slotId: draftSlotId,
|
|
fare: draftFare,
|
|
contactPhone: draftContactPhone,
|
|
idempotencyKey: key,
|
|
);
|
|
// The intent is spent. A further booking needs a new key or the server
|
|
// would replay this one.
|
|
_bookingIdempotencyKey = null;
|
|
orders.insert(0, booking);
|
|
activeReference = booking.reference;
|
|
notifyListeners();
|
|
return booking;
|
|
} on ApiException catch (e) {
|
|
// The slot went stale — filled up, or its window passed while the form
|
|
// was open. Both recover by re-reading the list, so drop the cached one
|
|
// here and let the screen send them back to slot selection.
|
|
if (e.needsFreshSlots) {
|
|
_invalidateSlots();
|
|
draftSlotId = null;
|
|
notifyListeners();
|
|
}
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
String _newIdempotencyKey() {
|
|
final now = DateTime.now().microsecondsSinceEpoch.toRadixString(36);
|
|
final salt = Object().hashCode.toRadixString(36);
|
|
return '$now-$salt';
|
|
}
|
|
|
|
// -------------------------------------------------------------- orders
|
|
|
|
final List<Booking> orders = [];
|
|
String? activeReference;
|
|
BookingStatus ordersTab = BookingStatus.active;
|
|
|
|
Booking? byReference(String? reference) {
|
|
if (reference == null) return null;
|
|
for (final o in orders) {
|
|
if (o.reference == reference) return o;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Booking? get trackedBooking => byReference(activeReference);
|
|
|
|
List<Booking> get activeBookings =>
|
|
orders.where((o) => o.status == BookingStatus.active).toList();
|
|
|
|
List<Booking> ofStatus(BookingStatus status) =>
|
|
orders.where((o) => o.status == status).toList();
|
|
|
|
/// Orders as the customer owns them: one row per pickup before collection,
|
|
/// one row per destination once the backend has created those orders.
|
|
List<OrderEntry> entriesOf(BookingStatus status) => [
|
|
for (final booking in ofStatus(status))
|
|
if (!booking.hasOrders)
|
|
OrderEntry(booking)
|
|
else
|
|
for (var i = 0; i < booking.destinations.length; i++)
|
|
OrderEntry(booking, i),
|
|
];
|
|
|
|
List<OrderEntry> get recentEntries => [
|
|
for (final booking in orders.where((o) => o.status != BookingStatus.active))
|
|
if (!booking.hasOrders)
|
|
OrderEntry(booking)
|
|
else
|
|
for (var i = 0; i < booking.destinations.length; i++)
|
|
OrderEntry(booking, i),
|
|
].take(3).toList();
|
|
|
|
/// Everything no longer running, newest first — Home's "Recent".
|
|
List<Booking> get recent =>
|
|
orders.where((o) => o.status != BookingStatus.active).take(3).toList();
|
|
|
|
/// Keyset cursor for the next page of orders; null when the last page has
|
|
/// been read or none has been read yet.
|
|
String? ordersCursor;
|
|
|
|
/// True while [loadMoreOrders] is in flight, so a scroll cannot start two.
|
|
bool loadingMoreOrders = false;
|
|
|
|
bool get hasMoreOrders => ordersCursor != null && ordersCursor!.isNotEmpty;
|
|
|
|
/// Pull-to-refresh on Home and Orders — reads the first page again.
|
|
///
|
|
/// The list is the server's: whatever comes back replaces what is on screen,
|
|
/// including an empty page, which is a real answer and not a failure.
|
|
Future<void> refreshOrders() async {
|
|
try {
|
|
final page = await api.getBookingPage();
|
|
// An empty first page is a real answer: this customer has no bookings.
|
|
// The list is the server's, so it is adopted whole rather than merged.
|
|
orders
|
|
..clear()
|
|
..addAll(page.bookings);
|
|
ordersCursor = page.nextCursor;
|
|
} on ApiException {
|
|
// A failed refresh leaves the last good list on screen.
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Appends the next keyset page. Keyset rather than offset, so a booking
|
|
/// arriving at the top mid-scroll cannot push a row into a second page and
|
|
/// show it twice.
|
|
Future<void> loadMoreOrders() async {
|
|
if (!hasMoreOrders || loadingMoreOrders) return;
|
|
loadingMoreOrders = true;
|
|
notifyListeners();
|
|
try {
|
|
final page = await api.getBookingPage(cursor: ordersCursor);
|
|
final seen = orders.map((o) => o.reference).toSet();
|
|
orders.addAll(page.bookings.where((b) => !seen.contains(b.reference)));
|
|
ordersCursor = page.nextCursor;
|
|
} on ApiException {
|
|
// Keep the rows already on screen; the cursor stays put so the next
|
|
// scroll retries the same page rather than skipping it.
|
|
} finally {
|
|
loadingMoreOrders = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void openTracking(String reference) {
|
|
activeReference = reference;
|
|
notifyListeners();
|
|
unawaited(refreshBooking(reference));
|
|
}
|
|
|
|
/// Re-reads one booking. Null means the server answered `304` — nothing
|
|
/// moved — so the copy already on screen stands.
|
|
Future<void> refreshBooking(String reference) async {
|
|
try {
|
|
final fresh = await api.getBooking(reference);
|
|
if (fresh == null) return;
|
|
final at = orders.indexWhere((o) => o.reference == reference);
|
|
if (at == -1) {
|
|
orders.insert(0, fresh);
|
|
} else {
|
|
orders[at] = fresh;
|
|
}
|
|
notifyListeners();
|
|
} on ApiException {
|
|
// Keep showing what we have.
|
|
}
|
|
}
|
|
|
|
void setOrdersTab(BookingStatus status) {
|
|
ordersTab = status;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Sends the optional address and recipient for an existing booking.
|
|
///
|
|
/// Accepted until the parcels are collected; after that the server answers
|
|
/// `409` and the Miler's record at the door is the one that counts.
|
|
Future<void> saveDestinationDetails(Booking booking, int index) async {
|
|
if (index < 0 || index >= booking.destinations.length) return;
|
|
final group = booking.destinations[index];
|
|
await api.updateDestinationDetails(
|
|
booking.reference,
|
|
// The server's own position for this stop, which is what the route
|
|
// addresses. The list position is only a fallback for a payload that
|
|
// did not send one.
|
|
group.index ?? index,
|
|
group.details,
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- push
|
|
|
|
/// Registers this device for milestone notifications.
|
|
///
|
|
/// Nothing calls this yet: no push SDK is wired in, so the app has no token
|
|
/// to hand over. The endpoint is reached through here so that adding one is
|
|
/// a two-line change rather than a new path through the state layer.
|
|
Future<void> registerPushToken(String token) async {
|
|
try {
|
|
await api.registerDevice(token);
|
|
} on ApiException catch (e) {
|
|
debugPrint('[PUSH] could not register device: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> unregisterPushToken(String token) async {
|
|
try {
|
|
await api.unregisterDevice(token);
|
|
} on ApiException catch (e) {
|
|
debugPrint('[PUSH] could not unregister device: $e');
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------- stage transitions
|
|
|
|
/// What the tracking screen's QA stepper calls.
|
|
///
|
|
/// It asks the server's own helper to move the booking, then re-reads it —
|
|
/// so what appears on screen is the stage the **server** recorded, never one
|
|
/// the client applied to its own copy.
|
|
Future<void> advanceStage(Booking booking, JourneyStage target) async {
|
|
try {
|
|
await api.setStage(booking.reference, target);
|
|
await refreshBooking(booking.reference);
|
|
} on ApiException catch (e) {
|
|
debugPrint('[OPS] stage override refused: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> cancelBooking(Booking booking, String? reason) async {
|
|
await api.cancelBooking(booking.reference, reason);
|
|
booking
|
|
..status = BookingStatus.cancelled
|
|
..cancelReason = reason
|
|
..cancellable = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
// ------------------------------------------------- serviceability cache
|
|
|
|
List<ServiceArea>? statesCache;
|
|
final Map<String, List<District>> districtCache = {};
|
|
List<PickupSlot>? slotsCache;
|
|
|
|
/// Only states we can actually serve reach the picker. A state with nothing
|
|
/// open is not an option the customer should have to read past.
|
|
Future<List<ServiceArea>> loadStates({bool refresh = false}) async {
|
|
if (refresh) statesCache = null;
|
|
final all = statesCache ??= await api.getServiceableStates();
|
|
return all.where((s) => s.hasOpenDistricts).toList();
|
|
}
|
|
|
|
/// Districts the picker offers: serviceable ones only. The full response is
|
|
/// cached so [upcomingDistricts] can name what is opening without putting
|
|
/// disabled tiles in front of anyone.
|
|
Future<List<District>> loadDistricts(
|
|
String stateCode, {
|
|
bool refresh = false,
|
|
}) async {
|
|
if (refresh) districtCache.remove(stateCode);
|
|
final all = districtCache[stateCode] ??= await api.getServiceableDistricts(
|
|
stateCode,
|
|
);
|
|
return all.where((d) => d.available).toList();
|
|
}
|
|
|
|
/// Every serviceable city, flat, for the send screen's city strip.
|
|
///
|
|
/// ── Why this exists rather than a state picker ──
|
|
///
|
|
/// The serviceability API is a hierarchy — states, then districts — because
|
|
/// that is how coverage is administered. It is not how anyone thinks about
|
|
/// sending a parcel: nobody picks "Tamil Nadu" on the way to picking
|
|
/// "Chennai", and on a network this size the state step is a screen that asks
|
|
/// a question with one useful answer.
|
|
///
|
|
/// So the hierarchy is flattened here, once, into the list the screen wants.
|
|
/// The districts come back in parallel — five states is five calls, not a
|
|
/// waterfall — and each city carries its state so [selectCity] can set both
|
|
/// codes the booking contract needs.
|
|
Future<List<CityOption>> loadCities({bool refresh = false}) async {
|
|
if (refresh) {
|
|
statesCache = null;
|
|
districtCache.clear();
|
|
}
|
|
final states = await loadStates();
|
|
final lists = await Future.wait([
|
|
for (final state in states) loadDistricts(state.code),
|
|
]);
|
|
return [
|
|
for (var i = 0; i < states.length; i++)
|
|
for (final district in lists[i])
|
|
CityOption(state: states[i], district: district),
|
|
];
|
|
}
|
|
|
|
/// Picks a city on the draft's only destination — both codes at once, since
|
|
/// the contract wants `stateCode` and `districtCode` together.
|
|
void selectCity(CityOption city, {int index = 0}) {
|
|
selectStateFor(index, city.state);
|
|
selectDistrictFor(index, city.district);
|
|
}
|
|
|
|
/// Replaces the draft's destinations with exactly these, in order.
|
|
///
|
|
/// The destination sheet answers with a list — one place, or several out of
|
|
/// one Miler visit — and this is the only thing that writes it onto the
|
|
/// draft. Clamped to [BookingLimits.maxDestinations] rather than trusted:
|
|
/// the sheet already obeys the cap, but the cap is a server answer that can
|
|
/// change between the sheet opening and this being called, and a draft that
|
|
/// exceeds it is a booking the network will refuse.
|
|
void setDestinations(List<CityOption> cities) {
|
|
if (cities.isEmpty) return;
|
|
final take = cities.take(limits.maxDestinations).toList();
|
|
draftDestinations = [for (var i = 0; i < take.length; i++) DestinationGroup()];
|
|
for (var i = 0; i < take.length; i++) {
|
|
selectCity(take[i], index: i);
|
|
}
|
|
}
|
|
|
|
/// Names of districts in this state that are not open yet, for a quiet
|
|
/// "Coming soon" line. Empty until the districts have been fetched.
|
|
List<String> upcomingDistricts(String stateCode) => (districtCache[stateCode] ?? const [])
|
|
.where((d) => !d.available)
|
|
.map((d) => d.name)
|
|
.toList();
|
|
|
|
/// Bumped whenever [slotsCache] is dropped, so a slot list already on screen
|
|
/// re-reads instead of showing what it fetched the first time.
|
|
int slotsEpoch = 0;
|
|
|
|
void _invalidateSlots() {
|
|
slotsCache = null;
|
|
slotsEpoch++;
|
|
}
|
|
|
|
Future<List<PickupSlot>> loadSlots({bool refresh = false}) async {
|
|
if (refresh) slotsCache = null;
|
|
final slots = slotsCache ??= await api.getPickupSlots(pickup: draftPickup);
|
|
for (final slot in slots) {
|
|
_slotIndex[slot.id] = slot;
|
|
}
|
|
return slots;
|
|
}
|
|
|
|
void clearCaches() {
|
|
statesCache = null;
|
|
districtCache.clear();
|
|
_invalidateSlots();
|
|
}
|
|
}
|