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

@@ -10,32 +10,72 @@ enum DoormileEnvironment { dev, staging, prod }
/// Build-time configuration: which backend this build talks to, and the
/// identity it sends on every request.
///
/// ── The one bypass, and why it is not the Miler app's bypass ──
/// ── The offline build, and the two rules that keep it honest ──
///
/// The Miler app shipped a `USE_NEW_API=false` flag that silently swapped
/// authentication for a bypass — any four digits logged you in as a fake rider,
/// with invented earnings, in a build that could ship.
/// with invented earnings, in a build that could ship. This app grew its own
/// version of that and it cost two days of hunting for bookings in the admin
/// console that had never left the phone.
///
/// This app has one API implementation that talks to a server, and exactly one
/// exception to it: [useDevData], now the **default in a debug build** because
/// staging has no SMS gateway and the login screen cannot otherwise be walked.
/// Three things keep it from becoming the flag that was deleted — it cannot
/// exist in a release build (`!kReleaseMode`, not a define), it is named on the
/// Account screen every time it is on, and nothing it does reaches a server.
/// Neither failure was caused by a fake existing. Both were caused by a fake
/// that did not announce itself and that nothing stopped from shipping. So the
/// offline build is back — it is genuinely useful with no SMS gateway on the
/// backend — under two rules that close exactly those holes:
///
/// * **It cannot ship.** [useDevData] is `false` in a release whatever the
/// defines say, so no customer can be shown invented data.
/// * **It says so.** [describe] leads with DEV DATA (offline) the entire
/// time it is on, and the Account screen prints it.
///
/// It is also opt-in — `flutter run` still talks to the real API — which is the
/// property whose absence caused the original mess.
///
/// Separately, [autoLoginIdentifier] skips the login SCREEN without faking the
/// login: it runs the real OTP exchange against the real server and ends up
/// holding a server-issued token. Use that one when the bookings need to be
/// real; use [useDevData] when there is no server worth reaching.
class AppConfig {
AppConfig._();
// ---------------------------------------------------------------- environment
static const String _envName = String.fromEnvironment(
'DM_ENV',
defaultValue: 'staging',
);
/// Empty when nobody said. The default is decided below rather than here,
/// because it is not the same answer for a release build as for a debug one.
static const String _envName = String.fromEnvironment('DM_ENV');
/// ── A shipped build is production unless it says otherwise ──
///
/// This defaulted to `staging` for every build, release included, and
/// `_stagingBase` is `_prodBase` — so a release APK built without
/// `--dart-define=DM_ENV=prod` talked to production while believing it was
/// staging. The URL was right and [isProd] was wrong, which is the worst of
/// the two to get wrong, because [isProd] is what the non-network guards read:
///
/// * the Account screen shows its Build panel — base URL and every build
/// flag — whenever [isProd] is false, so a shipped app showed diagnostics
/// to real customers;
/// * [allowStageOverride] and `setStage` both test it, so the guard against
/// driving the backend's QA stage endpoint was one dart-define away from
/// being off in the shop;
/// * [describe] labelled a production session "staging", which is what a
/// bug report would have carried.
///
/// Release now defaults to prod and debug/profile to staging, so forgetting
/// the flag fails safe in both directions: a shipped build cannot quietly
/// behave like a test one, and a developer build cannot quietly claim to be
/// production. An explicit `DM_ENV` still wins everywhere — including
/// `DM_ENV=staging` on a release build, which is a deliberate act.
///
/// This changes no network behaviour today: staging and prod resolve to the
/// same host until the backend names a staging one.
static DoormileEnvironment get environment => switch (_envName) {
'prod' || 'production' => DoormileEnvironment.prod,
'dev' || 'development' => DoormileEnvironment.dev,
_ => DoormileEnvironment.staging,
'staging' => DoormileEnvironment.staging,
_ => kReleaseMode
? DoormileEnvironment.prod
: DoormileEnvironment.staging,
};
static bool get isProd => environment == DoormileEnvironment.prod;
@@ -75,38 +115,13 @@ class AppConfig {
static String url(String path) =>
'$baseUrl/customer${path.startsWith('/') ? path : '/$path'}';
// ---------------------------------------------------------- development data
//
// ── The one exception to "no invented data", and it is now the debug default ──
//
// The shipping app has one API implementation and talks to a server; a build
// that cannot reach one shows its error state rather than inventing a
// booking, a slot or a price. [useDevData] is the single, loud exception.
//
// **Off by default again.** It was briefly the debug default so the app could
// be opened at all, and the cost showed up immediately: a booking made in
// that mode reaches no server and never appears in the admin console, which
// looks exactly like a broken integration. Skipping the login screen is worth
// a flag; faking every booking behind it is not.
//
// For a session without typing a code, use [autoLoginIdentifier] instead —
// it signs in against the **real** API, so everything after it is real.
//
// --dart-define=DM_MOCK=true offline fake, for UI work with no server
// --dart-define=DM_DEV_LOGIN=false keep the fake, but walk the entrance
//
// The guard has not moved: `!kReleaseMode`, so no define puts this in a
// release build. When it is on the app talks to [DevDoormileApi] instead of
// the network, `9876543210` + any 4-digit code signs in, and the Account
// screen says `DEV DATA (offline)`. **Nothing it does reaches a server or the
// admin console** — a booking made in this mode is not a booking.
static const bool _devDataRequested = bool.fromEnvironment(
'DM_MOCK',
defaultValue: false,
);
// ----------------------------------------------------------------- testing
/// True under `flutter test`, where the widget tests drive the whole journey
/// against [DevDoormileApi] and there is no server to reach.
/// True under `flutter test`, where the widget tests drive the journey
/// against the fake in [DevDoormileApi] and there is no server to reach.
///
/// The app itself never branches on this — it exists so a build-time
/// convenience cannot leak into a test run and quietly delete its coverage.
static bool get isTest {
if (kIsWeb) return false;
try {
@@ -116,30 +131,41 @@ class AppConfig {
}
}
/// True only when dev data was asked for — or we are under test — **and**
/// this is not a release build. There is no define that can turn it on in a
/// release.
static bool get useDevData => (_devDataRequested || isTest) && !kReleaseMode;
// ------------------------------------------------------------- offline data
//
// ── The mock is back, and this time it cannot ship ──
//
// An offline build was deleted on 15 Sep 2026 after it cost two rounds of
// hunting for bookings in the admin console that had never left the phone.
// That failure was not caused by the fake existing — it was caused by the
// fake being reachable without saying so, and by nothing stopping it
// reaching a release. Both are fixed here rather than by doing without it:
//
// * `!kReleaseMode` is unconditional, so no combination of defines puts
// invented data in front of a customer;
// * the Account screen prints DEV DATA (offline) whenever it is on, so a
// confusing session is one glance from being explained;
// * every booking it makes is stamped MOCK- and lives on the handset.
//
// It is opt-in, never a default: `flutter run` still talks to the real API,
// which is the property whose absence caused the original mess.
//
// flutter run --dart-define=DM_MOCK=true
static const bool _mockRequested = bool.fromEnvironment('DM_MOCK');
/// Opens the app already signed in, skipping the login screen.
/// Whether this build answers from [DevDoormileApi] instead of the network.
///
/// On by default wherever [useDevData] is, which is the point of the pair:
/// one `flutter run` and you are looking at Home. Only meaningful alongside
/// dev data — there is no one to sign in as without it, so a build talking to
/// the real API still signs in properly.
/// True under `FLUTTER_TEST` as well, so widget tests get the fake without
/// every test having to install it — and false in a release whatever is passed.
static bool get useDevData => (_mockRequested || isTest) && !kReleaseMode;
/// Whether an offline build opens straight on Home.
///
/// To walk the entrance itself — the sign-in, sign-up and verify screens —
/// turn this off and keep the dev data: `--dart-define=DM_DEV_LOGIN=false`,
/// where `9876543210` and any 4-digit code gets you in.
///
/// Never under `flutter test`: the widget tests drive the real entrance —
/// phone, code, and the guard that refuses to open the code screen when no
/// code was sent — and a session waiting for them at launch would delete
/// that coverage silently.
static bool get devAutoLogin =>
useDevData &&
!isTest &&
const bool.fromEnvironment('DM_DEV_LOGIN', defaultValue: true);
/// Deliberately NOT true under `FLUTTER_TEST`. The widget tests drive the
/// real entrance — phone, code, and the guard that refuses to open the code
/// screen when nothing was sent — and a session waiting for them at launch
/// would delete that coverage without a single test failing to say so.
static bool get devAutoLogin => _mockRequested && !isTest && !kReleaseMode;
// --------------------------------------------------------------- auto sign-in
//
@@ -174,8 +200,12 @@ class AppConfig {
static String get autoLoginCode =>
kReleaseMode ? '' : const String.fromEnvironment('DM_LOGIN_CODE');
/// Never under `flutter test`: the widget tests drive the real entrance —
/// phone, code, and the guard that refuses to open the code screen when no
/// code was sent — and a session waiting for them at launch would delete that
/// coverage silently.
static bool get hasAutoLogin =>
autoLoginIdentifier.isNotEmpty && autoLoginCode.isNotEmpty;
!isTest && autoLoginIdentifier.isNotEmpty && autoLoginCode.isNotEmpty;
// ------------------------------------------------------------- ops QA only
@@ -194,10 +224,10 @@ class AppConfig {
defaultValue: false,
);
/// Whether the tracking screen offers its stage stepper. On a dev build it
/// walks [DevDoormileApi]'s in-memory booking; on staging it drives the real
/// QA endpoint.
static bool get showStageStepper => useDevData || allowStageOverride;
/// Whether the tracking screen offers its stage stepper. It drives the
/// backend's own QA endpoint, so it is only ever offered where that endpoint
/// exists.
static bool get showStageStepper => allowStageOverride;
// ------------------------------------------------------- development access
//
@@ -233,7 +263,7 @@ class AppConfig {
/// True when this build got its session from somewhere other than a real
/// sign-in — a pasted dev token, or dev data where any code is accepted.
static bool get authBypassed => hasDevToken || useDevData || hasAutoLogin;
static bool get authBypassed => hasDevToken || hasAutoLogin || devAutoLogin;
// -------------------------------------------------------------- client identity
@@ -271,7 +301,12 @@ class AppConfig {
/// Names the dev token whenever one is in use: a build whose session did not
/// come from a sign-in must say so on screen.
static String get describe =>
'${environment.name} · ${useDevData ? 'DEV DATA (offline)' : baseUrl}'
// Offline first and loudest. A session answered by the fake is the one
// state where nothing on screen came from a server, and the whole reason
// the previous mock was deleted was that a build in it looked exactly
// like a build that was not.
'${useDevData ? 'DEV DATA (offline) · ' : ''}'
'${environment.name} · ${useDevData ? 'no network' : baseUrl}'
'${allowStageOverride ? ' · STAGE OVERRIDE' : ''}'
'${hasDevToken ? ' · DEV TOKEN' : ''}'
'${hasAutoLogin ? ' · AUTO LOGIN ($autoLoginIdentifier)' : ''}';

View File

@@ -1,7 +1,7 @@
import 'dart:math';
import 'doormile_api.dart';
import 'models.dart';
import 'package:doormile_cx/data/doormile_api.dart';
import 'package:doormile_cx/data/models.dart';
/// In-memory backend for **development and tests only**.
///

View File

@@ -7,14 +7,24 @@ 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.
/// **[LiveDoormileApi] is what ships,** 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.
/// [DevDoormileApi] answers instead when [AppConfig.useDevData] is set — under
/// `FLUTTER_TEST`, or a debug build given `--dart-define=DM_MOCK=true`. That
/// getter is `false` in a release whatever is passed, so a shipped build has
/// one implementation and a booking a customer makes is one the server
/// accepted.
///
/// ── Why this is guarded so tightly ──
///
/// An offline build was deleted from this app on 15 Sep 2026 after it cost two
/// rounds of hunting for bookings in the admin console that had never left the
/// phone. What made that expensive was not the fake existing — it was that the
/// fake was reachable without announcing itself, and that nothing stopped it
/// reaching a release. Both are closed now: the release guard above, and the
/// Account screen printing DEV DATA (offline) the whole time it is on.
///
/// Screens never see this type at all — they call [AppState], which holds it.
///
@@ -25,8 +35,12 @@ abstract class 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.
/// The API this build talks to.
///
/// [LiveDoormileApi] unless the build asked for offline data, which a
/// release build cannot do — see [AppConfig.useDevData]. The check is here
/// rather than at the call sites so there is exactly one place that decides,
/// and nothing downstream has to know which one it got.
static DoormileApi get instance =>
_instance ??= AppConfig.useDevData ? DevDoormileApi() : LiveDoormileApi();

View File

@@ -252,6 +252,28 @@ class ServiceArea {
}
/// A district inside a serviceable state.
/// One serviceable city: a district, plus the state it belongs to.
///
/// The API serves coverage as a hierarchy and the send screen offers it as one
/// flat strip, so something has to carry the state along with the district —
/// `POST /customer/bookings` wants both codes on every destination, and a
/// district code alone cannot be sent.
class CityOption {
const CityOption({required this.state, required this.district});
final ServiceArea state;
final District district;
/// "Chennai, Tamil Nadu" — what the rest of the app already calls a
/// destination.
String get label => '${district.name}, ${state.name}';
/// The one line under the name in the strip: what this city is promised,
/// falling back to the state's transit tag when the district has no promise
/// of its own. Null when the backend told us neither.
String? get note => district.promise ?? state.transitTag;
}
class District {
const District({
required this.code,