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:
177
lib/data/doormile_api.dart
Normal file
177
lib/data/doormile_api.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
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 {}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user