Replace the customer app with Doormile CX
Book a pickup, track it to delivery — the rebuilt customer app. - Design language from doormile-screens.html: brand #8F0F06, Manrope + Geist Mono (variable fonts), bordered cards instead of shadows, crimson brand headers, sliding tab indicator, mono for anything read digit by digit. - lib/data (one live API implementation, plus a debug-only offline fake), lib/state, lib/ui (tokens, widgets, screens). - 84 tests, plus a design snapshot harness that renders every screen with the real fonts: flutter test test/design_snapshot_test.dart --run-skipped --update-goldens This replaces the previous app (pubspec 'doormile', app id com.doormile.customer). That tree remains in history at 6c7d656; note its android/app/google-services.json is not carried over, and the application id here is in.doormile.customer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
24
lib/state/app_scope.dart
Normal file
24
lib/state/app_scope.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'app_state.dart';
|
||||
|
||||
/// Makes [AppState] available to the widget tree and rebuilds dependents when
|
||||
/// it changes. Deliberately dependency-free — no external state package.
|
||||
class AppScope extends InheritedNotifier<AppState> {
|
||||
const AppScope({super.key, required AppState state, required super.child})
|
||||
: super(notifier: state);
|
||||
|
||||
/// Subscribes the calling widget to changes.
|
||||
static AppState of(BuildContext context) {
|
||||
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
|
||||
assert(scope != null, 'AppScope.of() called outside an AppScope');
|
||||
return scope!.notifier!;
|
||||
}
|
||||
|
||||
/// Reads without subscribing — for callbacks and one-shot lookups.
|
||||
static AppState read(BuildContext context) {
|
||||
final scope = context.getInheritedWidgetOfExactType<AppScope>();
|
||||
assert(scope != null, 'AppScope.read() called outside an AppScope');
|
||||
return scope!.notifier!;
|
||||
}
|
||||
}
|
||||
728
lib/state/app_state.dart
Normal file
728
lib/state/app_state.dart
Normal file
@@ -0,0 +1,728 @@
|
||||
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;
|
||||
unawaited(refreshOrders());
|
||||
} else if (AppConfig.hasAutoLogin) {
|
||||
await _autoSignIn();
|
||||
} else if (AppConfig.devAutoLogin) {
|
||||
// Dev builds only, and only when asked with DM_DEV_LOGIN: open straight
|
||||
// into the app without the login screen. Guarded in [AppConfig] so no
|
||||
// define can do this in a release. Without the flag, a dev build still
|
||||
// lands on login, where any 4-digit code signs in.
|
||||
debugPrint('[AUTH] DM_DEV_LOGIN — opening without the login screen');
|
||||
signIn(
|
||||
const Customer(
|
||||
id: 'cust_dev',
|
||||
name: 'Dev Customer',
|
||||
phone: '+91 98765 43210',
|
||||
email: 'dev@doormile.com',
|
||||
),
|
||||
);
|
||||
}
|
||||
} 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
|
||||
/// [AppConfig.useDevData] — 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;
|
||||
try {
|
||||
// The request may come back `sent: false` on the resend cooldown; that
|
||||
// is fine, the code already issued is still the one Redis is holding.
|
||||
await api.sendOtp(identifier);
|
||||
final verified = await api.verifyOtp(identifier, AppConfig.autoLoginCode);
|
||||
customer = verified;
|
||||
unawaited(refreshOrders());
|
||||
debugPrint('[AUTH] DM_LOGIN_AS — signed in as $identifier');
|
||||
} catch (e) {
|
||||
debugPrint('[AUTH] DM_LOGIN_AS failed for $identifier: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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;
|
||||
|
||||
void startBooking() {
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
Future<Booking> confirmBooking() async {
|
||||
final key = _bookingIdempotencyKey ??= _newIdempotencyKey();
|
||||
try {
|
||||
final booking = await api.createBooking(
|
||||
pickup: draftPickup ?? pickup!,
|
||||
destinations: draftDestinations,
|
||||
slotId: draftSlotId,
|
||||
fare: draftFare,
|
||||
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();
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user