Booking and sign-in flow, and the offline build back under guard

Picks up where 0d66627 left off. Three things.

SIGN-IN, CUT TO THE QUESTION IT ASKS
It opened with a quarter-screen crimson hero carrying a lockup, a CUSTOMER
badge, a display-size promise and a sub-line, then a Phone/Email toggle, then a
labelled field, then a card explaining what a verification code is. Six things
to read before the one thing to do.

Nobody arrives at a sign-in screen needing to be sold the product. It is a
heading, a field and a button now, which is where Uber, Bolt and Porter put
them. The toggle became one quiet line under the field — choosing a method was
the first decision on the screen, before the customer had seen what was being
asked, and almost everyone uses the phone. The privacy card went: it explained
that a code would be sent, which the next screen demonstrates a second later.

`DmTextField.label` is nullable for this — "Enter your mobile number" above a
field captioned "Phone number" is one sentence printed twice.

BOOKING, DOWN TO ONE SCREENFUL
Landmark, recipient name and recipient phone are all optional and were all
drawn at the weight of the two fields that are not, putting six rows of "you
may skip this" between the address and the button. They fold behind one row
that counts what is filled in rather than just saying "optional".

Four crimson section heads became one. An accent used five times on a screen is
not an accent; crimson now marks the destination, which is the only choice that
changes the price.

Together those put the window, the package count and the CTA above the fold.

THE OFFLINE BUILD, BACK, UNDER TWO RULES
Deleted on 15 Sep after it cost two rounds of hunting for bookings in the admin
console that had never left the phone. That was not caused by the fake
existing — it was caused by a fake that did not announce itself and that
nothing stopped from shipping. Both are closed:

  * `useDevData` is false in a release whatever the defines say;
  * `describe` leads with DEV DATA (offline) and shows "no network" rather than
    a host the build never contacts.

It is opt-in — `flutter run` still talks to the real API — which is the
property whose absence caused the original mess. `devAutoLogin` is deliberately
false under FLUTTER_TEST so the widget tests keep driving the real entrance.

    flutter run --dart-define=DM_MOCK=true

86 tests green, analyze clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
This commit is contained in:
2026-09-21 13:10:28 +05:30
parent 0d66627c3c
commit 8207e27a97
37 changed files with 1382 additions and 1566 deletions

View File

@@ -94,22 +94,21 @@ class AppState extends ChangeNotifier {
if (restored != null) {
customer = restored;
unawaited(refreshOrders());
} 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');
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
@@ -126,23 +125,44 @@ class AppState extends ChangeNotifier {
/// 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.
/// 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 {
// 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;
customer = await api.verifyOtp(identifier, code);
unawaited(refreshOrders());
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);
unawaited(refreshOrders());
debugPrint('[AUTH] DM_LOGIN_AS — signed in as $identifier after a request');
} catch (e) {
debugPrint('[AUTH] DM_LOGIN_AS failed for $identifier: $e');
debugPrint(
'[AUTH] DM_LOGIN_AS failed for $identifier: $e — '
'the code was refused, so the login screen opens instead',
);
}
}
@@ -320,7 +340,19 @@ class AppState extends ChangeNotifier {
/// The in-progress pickup. One destination is the common case; the customer
/// can add more, all collected in the same visit.
Place? draftPickup;
///
/// 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;
@@ -695,6 +727,43 @@ class AppState extends ChangeNotifier {
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);
}
/// 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 [])