Files
doormile_customer_app/lib/data/app_config.dart
Thiru-tenext 8207e27a97 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
2026-09-21 13:10:28 +05:30

314 lines
14 KiB
Dart

import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
/// Which backend this build talks to.
///
/// Set at compile time: `flutter run --dart-define=DM_ENV=prod`.
enum DoormileEnvironment { dev, staging, prod }
/// Build-time configuration: which backend this build talks to, and the
/// identity it sends on every request.
///
/// ── 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. 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.
///
/// 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
/// 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,
'staging' => DoormileEnvironment.staging,
_ => kReleaseMode
? DoormileEnvironment.prod
: DoormileEnvironment.staging,
};
static bool get isProd => environment == DoormileEnvironment.prod;
// ------------------------------------------------------------------- base URL
/// Overrides the per-environment default. The staging host is not yet
/// confirmed by the backend team, so staging builds are expected to pass
/// `--dart-define=DM_API_BASE=https://<staging host>/api/v1`.
static const String _baseOverride = String.fromEnvironment('DM_API_BASE');
static const String _prodBase = 'https://api.doormile.com/api/v1';
/// Placeholder until the backend team names the staging host. A staging build
/// without `DM_API_BASE` therefore talks to production, which is wrong but
/// loud — it is better than silently talking to a host that does not exist.
static const String _stagingBase = _prodBase;
static String get baseUrl {
if (_baseOverride.isNotEmpty) return _stripTrailingSlash(_baseOverride);
return switch (environment) {
DoormileEnvironment.prod => _prodBase,
DoormileEnvironment.staging => _stagingBase,
DoormileEnvironment.dev => _stripTrailingSlash(
const String.fromEnvironment(
'DM_API_BASE_DEV',
defaultValue: 'http://10.0.2.2:8080/api/v1',
),
),
};
}
static String _stripTrailingSlash(String s) =>
s.endsWith('/') ? s.substring(0, s.length - 1) : s;
/// Everything in this document lives under one namespace.
static String url(String path) =>
'$baseUrl/customer${path.startsWith('/') ? path : '/$path'}';
// ----------------------------------------------------------------- testing
/// 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 {
return Platform.environment.containsKey('FLUTTER_TEST');
} catch (_) {
return false;
}
}
// ------------------------------------------------------------- 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');
/// Whether this build answers from [DevDoormileApi] instead of the network.
///
/// 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.
///
/// 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
//
// ── Skipping the login SCREEN without faking the login ──
//
// This is the flag to reach for when you want the app to open on Home and
// still be a real customer: at launch, with no stored session, the app runs
// the actual `POST /customer/auth/otp/request` + `/verify` pair for you. The
// token it gets back is the server's, every call after it is authorised
// normally, and a booking made in this build lands in `pickupbookings` and
// shows up in the admin console.
//
// --dart-define=DM_LOGIN_AS=9876543210 --dart-define=DM_LOGIN_CODE=1234
//
// The code has to be one the server will accept. Two ways to have one:
// set `CX_STAGING_OTP` on a non-production backend (a fixed code, refused
// outright when ENV=production), or read the code the backend logged — with
// no SMS gateway registered its `logSender` writes every code to the
// application log and reports the send as successful.
//
// It only has to work once per install: the session is persisted, so later
// launches restore it and these defines stop mattering.
//
// `!kReleaseMode`, like everything else here, and named on the Account
// screen whenever it is on.
/// The phone number or email to sign in as.
static String get autoLoginIdentifier =>
kReleaseMode ? '' : const String.fromEnvironment('DM_LOGIN_AS');
/// The verification code that identifier will accept.
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 =>
!isTest && autoLoginIdentifier.isNotEmpty && autoLoginCode.isNotEmpty;
// ------------------------------------------------------------- ops QA only
/// Mirrors the backend's own `CX_ALLOW_STAGE_OVERRIDE`: the QA helper at
/// `POST /customer/ops/bookings/{reference}/stage` exists only off
/// production, and only when ops has turned it on.
///
/// Turning it on here does not create the endpoint — a build that asks for
/// it against a server that has it disabled gets a refusal, which is the
/// right outcome. It only decides whether the control is offered.
static bool get allowStageOverride =>
!kReleaseMode &&
!isProd &&
const bool.fromEnvironment(
'DM_ALLOW_STAGE_OVERRIDE',
defaultValue: false,
);
/// 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
//
// ── The dev token is not a login bypass ──
//
// The Miler app shipped one of those: `USE_NEW_API=false` handed you a fake
// "Demo Rider" on any four digits, with invented earnings. It was deleted,
// and the reason is worth restating — a build flag that swaps authentication
// for a bypass is not a development convenience, because the flag ships with
// the binary and nothing in the UI says which mode you are in.
//
// So neither switch below authenticates anybody:
//
// [devToken] carries a **real, server-issued token** you already hold. The
// server still authorises every request; this only spares you the OTP round
// trip that staging cannot complete without an SMS gateway. It is not a
// sign-in bypass — a revoked token signs the build straight back out.
//
// It is `!kReleaseMode`, so no define can put it into a release build, and it
// is named on the Account screen whenever it is on.
/// A real access token, pasted in at build time:
/// `--dart-define=DM_DEV_TOKEN=eyJ...`
static String get devToken =>
kReleaseMode ? '' : const String.fromEnvironment('DM_DEV_TOKEN');
/// The matching refresh token, if you have one. Without it the session
/// simply dies when the access token expires, and you paste a fresh one.
static String get devRefreshToken =>
kReleaseMode ? '' : const String.fromEnvironment('DM_DEV_REFRESH_TOKEN');
static bool get hasDevToken => devToken.isNotEmpty;
/// 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 || hasAutoLogin || devAutoLogin;
// -------------------------------------------------------------- client identity
/// `X-Client: doormile-cx/1.0.0+1`. Passed in at build time because reading
/// it at runtime would mean another plugin for one string.
static const String appVersion = String.fromEnvironment(
'DM_APP_VERSION',
defaultValue: '1.0.0+1',
);
static String get clientHeader => 'doormile-cx/$appVersion';
static String get platformHeader {
if (kIsWeb) return 'web';
try {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
return Platform.operatingSystem;
} catch (_) {
return 'unknown';
}
}
// ------------------------------------------------------------------ timeouts
static const Duration requestTimeout = Duration(seconds: 15);
/// Booking creation is allowed longer: the backend's own budget for it is
/// 1.2s p95, but it writes across several tables and we would rather wait
/// than orphan a booking the server did create.
static const Duration writeTimeout = Duration(seconds: 30);
/// A one-line summary for the Account screen and for bug reports.
///
/// 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 =>
// 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)' : ''}';
}