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>
178 lines
6.5 KiB
Dart
178 lines
6.5 KiB
Dart
import 'app_config.dart';
|
|
import 'dev_doormile_api.dart';
|
|
import 'live_doormile_api.dart';
|
|
import 'models.dart';
|
|
|
|
export 'api_exception.dart';
|
|
|
|
/// The service surface the app is written against.
|
|
///
|
|
/// **A shipping build has one implementation: [LiveDoormileApi],** against
|
|
/// `/customer/*` on `api.doormile.com`. When the server cannot be reached the
|
|
/// screens show their designed error state, which is the only honest answer.
|
|
///
|
|
/// The single exception is [DevDoormileApi], reached only when
|
|
/// [AppConfig.useDevData] is set — a `!kReleaseMode` guard, so no define swaps
|
|
/// invented data into a release. It exists because the real backend has no SMS
|
|
/// gateway, so there is otherwise no way to sign in and exercise the flow.
|
|
///
|
|
/// Screens never see this type at all — they call [AppState], which holds it.
|
|
///
|
|
/// A method the backend owns but no screen drives yet has a default here that
|
|
/// does nothing rather than throwing.
|
|
abstract class DoormileApi {
|
|
DoormileApi();
|
|
|
|
static DoormileApi? _instance;
|
|
|
|
/// The API this build talks to: the live backend, or [DevDoormileApi] when
|
|
/// [AppConfig.useDevData] allows it — which is never in a release build.
|
|
static DoormileApi get instance =>
|
|
_instance ??= AppConfig.useDevData ? DevDoormileApi() : LiveDoormileApi();
|
|
|
|
/// Replaces the singleton. Tests inject their double through here.
|
|
static void overrideInstance(DoormileApi? api) => _instance = api;
|
|
|
|
/// Called when the refresh chain dies and the customer must sign in again.
|
|
/// Set by [AppState].
|
|
void Function()? onSessionLost;
|
|
|
|
// --------------------------------------------------------------------- auth
|
|
|
|
/// Asks for a code. The answer describes the challenge — how many digits it
|
|
/// has and how long before it can be resent — so the screen is built from
|
|
/// what the server said rather than from constants.
|
|
Future<OtpChallenge> sendOtp(String identifier);
|
|
|
|
/// Creates the account **and** sends the first code, so it answers with the
|
|
/// same challenge [sendOtp] does.
|
|
Future<OtpChallenge> signUp({
|
|
required String name,
|
|
required String phone,
|
|
String? email,
|
|
});
|
|
|
|
/// [name] is set when verifying a freshly created account.
|
|
Future<Customer> verifyOtp(String identifier, String code, {String? name});
|
|
|
|
/// Restores a persisted session at launch, or null when there is none.
|
|
Future<Customer?> restoreSession() async => null;
|
|
|
|
/// Revokes the refresh token and unregisters the push token.
|
|
Future<void> signOut() async {}
|
|
|
|
/// Re-reads the signed-in customer — `GET /customer/auth/me`.
|
|
Future<Customer?> me() async => null;
|
|
|
|
// ----------------------------------------------------------- serviceability
|
|
|
|
Future<List<ServiceArea>> getServiceableStates();
|
|
|
|
Future<List<District>> getServiceableDistricts(String stateCode);
|
|
|
|
/// Synchronous district lookup for a code we already hold, backed by
|
|
/// whatever [getServiceableDistricts] last returned.
|
|
District? districtByCode(String? code);
|
|
|
|
// -------------------------------------------------------------------- slots
|
|
|
|
Future<List<PickupSlot>> getPickupSlots({Place? pickup});
|
|
|
|
PickupSlot? slotById(String? id);
|
|
|
|
// ----------------------------------------------------------------- location
|
|
|
|
Future<Place> reverseGeocode({double? lat, double? lng});
|
|
|
|
/// An empty query returns the customer's saved and recent places, which is
|
|
/// what the search sheet shows the moment it opens.
|
|
///
|
|
/// [lat]/[lng] bias the results towards where the customer is looking —
|
|
/// "MG Road" is in most Indian cities, and the one they mean is the near one.
|
|
Future<List<Place>> searchPlaces(String query, {double? lat, double? lng});
|
|
|
|
// ------------------------------------------------------------------- limits
|
|
|
|
Future<BookingLimits> getBookingLimits({Place? pickup});
|
|
|
|
// --------------------------------------------------------------------- fare
|
|
|
|
Future<FareEstimate> estimateFare({
|
|
required Place pickup,
|
|
required List<DestinationGroup> destinations,
|
|
});
|
|
|
|
// ------------------------------------------------------------------ booking
|
|
|
|
/// Creates the pickup booking. No tracking number yet — those are minted per
|
|
/// destination when the Miler completes the pickup.
|
|
///
|
|
/// [idempotencyKey] must be **held across retries of the same intent**. A new
|
|
/// key is a new booking, which is exactly what a double tap must not create.
|
|
Future<Booking> createBooking({
|
|
required Place pickup,
|
|
required List<DestinationGroup> destinations,
|
|
required String? slotId,
|
|
FareEstimate? fare,
|
|
String? idempotencyKey,
|
|
});
|
|
|
|
Future<void> cancelBooking(String reference, String? reason);
|
|
|
|
/// One page of the customer's bookings, newest first.
|
|
Future<BookingPage> getBookingPage({
|
|
BookingStatus? status,
|
|
String? cursor,
|
|
int limit = 20,
|
|
});
|
|
|
|
/// One booking, fresh. Null means the server answered `304` — nothing has
|
|
/// changed since the last read, so the caller keeps what it has.
|
|
Future<Booking?> getBooking(String reference);
|
|
|
|
/// One order by its tracking number — for a push deep link.
|
|
Future<Booking?> getOrder(String trackingId) async => null;
|
|
|
|
/// Adds the optional address and recipient after booking, up to collection.
|
|
Future<void> updateDestinationDetails(
|
|
String reference,
|
|
int index,
|
|
DeliveryDetails details,
|
|
) async {}
|
|
|
|
/// Backend policy decides this; the UI only asks.
|
|
bool isCancellable(JourneyStage stage);
|
|
|
|
// ------------------------------------------------------------------ ops QA
|
|
|
|
/// Moves a booking to [stage] through the backend's own non-production QA
|
|
/// helper — `POST /customer/ops/bookings/{reference}/stage`.
|
|
///
|
|
/// It exists so the tracking screen's stepper can walk a **real** staging
|
|
/// booking. The stage that then appears on screen is the one the server
|
|
/// recorded, not one the client wished for. Refused on production.
|
|
Future<void> setStage(String reference, JourneyStage stage) async {}
|
|
|
|
// ------------------------------------------------------------------ devices
|
|
|
|
Future<void> registerDevice(String token) async {}
|
|
|
|
Future<void> unregisterDevice(String token) async {}
|
|
|
|
// ------------------------------------------------------- profile & addresses
|
|
|
|
Future<Customer?> getProfile() async => null;
|
|
|
|
Future<Customer?> updateProfile({String? name, String? email}) async => null;
|
|
|
|
Future<List<SavedPlace>> getSavedLocations() async => const [];
|
|
|
|
Future<SavedPlace?> addSavedLocation(Place place, {String? label}) async =>
|
|
null;
|
|
|
|
Future<SavedPlace?> updateSavedLocation(SavedPlace place) async => null;
|
|
|
|
Future<void> deleteSavedLocation(String id) async {}
|
|
|
|
}
|