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>
279 lines
12 KiB
Dart
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)' : ''}';
|
|
}
|