── The splash, in three beats ── Crimson edge to edge with the truck running across it in white; the invert; then the mark. The red starts before Flutter does. Four surfaces painted white before any Dart runs — launch_background at both API levels, windowSplashScreenBackground on Android 12+ in light and dark, and the iOS launch storyboard — and leaving any one of them white makes the launch a white flash followed by a red one. That flash is the only part of a launch a customer consciously notices. The invert is one gesture rather than a fade. A white truck on a background turning white is an invisible truck, so the ground lightens as the truck darkens, off one controller, and the screen turns itself inside out with the truck still on it. Fading it would have left the screen empty for the moment before the mark. The truck's colour is no longer its own: it is painted through srcIn, so the file's palette is discarded and only alpha survives. A replacement Lottie now needs no preparation, and tool/lottie_brand.py is off this path. `splash.json` is a seamless 3.9s loop — frame 0 is frame 60, the truck never arrives or departs — so there is no completion to hand over on. _truckBeat is a decision about how long a launch may hold somebody, not a property of the file. The comment claiming six seconds was wrong on both counts. ── Three things the splash was getting wrong quietly ── It showed the wrong logo: doormile-icon.png, the previous mark, to a customer who had just tapped the new one. tool/icons.py now cuts doormile-mark.png from the same master alpha as the launcher icon, so they cannot drift again. The fallback loader was invisible on red. _RoadLoader painted in DmColors.brand on what used to be a white screen; on crimson that is crimson on crimson, and it drew nothing at all on exactly the devices that had fallen back to it. The mark appeared and left in the same frame — _minimum was the sum of the beats exactly, so the clock ran out as the entrance finished. Hence _markHold. ── The truck was not in the middle ── Not a layout bug: both beats sit in a Center and always did. The artwork is drawn low and to the right inside its own 500x500 composition, so a centred widget rendered an off-centre picture — 30pt right, 36pt down. splash_centring_test.dart renders a frame at phone size and density, finds the ink and fails if either beat drifts. It is the only form of test that could have caught this, and the one that will catch it again when the Lottie is replaced, which is when the correction goes stale. Two things it taught: one enormous pump does not let the splash's async start-up chain advance, and capturing at pixelRatio 1 rasterizes the speed lines too faintly to detect, which truncates the bounding box and moves the measured centre by 12pt. ── The destination sheet stops resizing ── Tapping ONE TOUCH opened a tall sheet that snapped shorter a few frames later. DmAsyncList renders four skeleton rows while it loads — 302pt — and the states that replace them are nearer 200; the sheet was Flexible, so it was as tall as whichever state its content happened to be in, and the modal is still animating up while that swap happens. The list now lives in a box of one height. That also removes a second resize: the sheet grew from 48% of the screen to 74% when a state was picked. Both steps now measure 64% and it never changes size again. And DmAsyncList takes initialItems, fed by AppState.cachedCities: FutureBuilder reports `waiting` on its first build even for an already-complete future, so a warm cache still flashed a skeleton over data it already had. Three skeleton rows here rather than four — sheet_stability_test caught that 302pt overflows the smallest box the clamp can produce.
877 lines
31 KiB
Dart
877 lines
31 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.
|
|
/// The cities already in hand, or null when any part still has to be fetched.
|
|
///
|
|
/// The synchronous half of [loadCities], for a caller that would otherwise
|
|
/// render a loading state over data it already has. Null unless *every*
|
|
/// state's districts are cached, because a partial answer shown as a
|
|
/// complete one is a list of cities with some quietly missing.
|
|
List<CityOption>? get cachedCities {
|
|
final states = statesCache;
|
|
if (states == null) return null;
|
|
|
|
final out = <CityOption>[];
|
|
for (final state in states) {
|
|
final districts = districtCache[state.code];
|
|
if (districts == null) return null;
|
|
for (final district in districts) {
|
|
out.add(CityOption(state: state, district: district));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|