Files
doormile_customer_app/lib/data/app_config.dart
Thiru-tenext 0d66627c3c 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>
2026-09-15 16:07:33 +05:30

279 lines
12 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 one bypass, and why it is not the Miler app's bypass ──
///
/// 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 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.
class AppConfig {
AppConfig._();
// ---------------------------------------------------------------- environment
static const String _envName = String.fromEnvironment(
'DM_ENV',
defaultValue: 'staging',
);
static DoormileEnvironment get environment => switch (_envName) {
'prod' || 'production' => DoormileEnvironment.prod,
'dev' || 'development' => DoormileEnvironment.dev,
_ => 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'}';
// ---------------------------------------------------------- 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,
);
/// True under `flutter test`, where the widget tests drive the whole journey
/// against [DevDoormileApi] and there is no server to reach.
static bool get isTest {
if (kIsWeb) return false;
try {
return Platform.environment.containsKey('FLUTTER_TEST');
} catch (_) {
return false;
}
}
/// 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;
/// Opens the app already signed in, skipping the login screen.
///
/// 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.
///
/// 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);
// --------------------------------------------------------------- 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');
static bool get hasAutoLogin =>
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. 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;
// ------------------------------------------------------- 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 || useDevData || hasAutoLogin;
// -------------------------------------------------------------- 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 =>
'${environment.name} · ${useDevData ? 'DEV DATA (offline)' : baseUrl}'
'${allowStageOverride ? ' · STAGE OVERRIDE' : ''}'
'${hasDevToken ? ' · DEV TOKEN' : ''}'
'${hasAutoLogin ? ' · AUTO LOGIN ($autoLoginIdentifier)' : ''}';
}