── The full-address path was reaching the Miler empty ──
`DestinationGroup.toBookingJson` spread its details FLAT across the
destination. The contract nests them under `details{}`, and a destination
carrying keys the server does not recognise is accepted without a word — so
every building number, street, landmark, recipient name, recipient phone and
pin a customer typed was written, answered 201, and thrown away. The Miler
arrived with a district.
Four more on the same call. The destination pin spelled `latitude`/`longitude`
— the same spelling that answered 422 unserviceable for months on the pickup
before it was fixed there and missed here. A PATCH that sent `null` to clear a
field, with a comment saying so, when the server writes only non-nil values, so
a landmark could be added and never removed. Per-destination `instructions`
folded into the visit's one `remarks` line on the belief the contract had no
per-destination note; it has one. And `contactName`/`contactPhone` on the
pickup object, which the create contract has no room for and drops.
The fix ships unverified, deliberately. If `details{}` is also the wrong shape
the fields drop exactly as they do today — it cannot be worse, and holding it
costs every full-address booking in the meantime. docs/BACKEND_CHANGES.md asks
for the confirmation; tool/verify_booking.sh runs it in one command.
── Who the Miler rings ──
One number reaches the rider and it is the account's: `GET /miler/bookings`
returns a single `customerphone`, verified against production and written down
in the rider app's own stop_contact.dart. So "Someone else is handing it over?"
was collecting a number that reached nobody.
Review now shows the number that will actually be dialled, and the handover
person travels in `remarks` with a name, labelled for whoever reads it. Both
screens say plainly that the rider's call button still dials the account —
better than letting somebody hand their parcel to a neighbour believing
otherwise.
── Account's rows led nowhere ──
Two had no `onTap` at all — a chevron pointing at a page that did not exist —
and three answered with a toast. Five rows making a promise, one keeping it.
Notifications, Payment, Help and About are real screens now, written to one
rule: say only what is true of this app today. There is no notification
endpoint, no stored payment instrument and no push SDK wired in, so none of
them pretends to manage any of that. Support shows no contact block at all
rather than a number that rings nowhere — AppConfig carries the fields empty
until somebody fills them in.
── ONE TOUCH is one sheet ──
It was two in sequence with a dismissal between them, and the destination step
made you open a state to see any city — two levels of navigation for something
its own search already flattened. One flat list headed by state, which is also
the answer to "where do you deliver?", and one surface that changes its
question instead of closing so another can open.
Home says the reach in a line, and it needed two fixes to appear at all:
`cachedCities` walked closed states looking for districts that are only fetched
for open ones, and `loadCities` filled two caches while notifying nobody.
── Sending a second parcel ──
`maxDestinations` is 1 in production, so two parcels for two places means
booking twice — and that cost the whole flow twice, re-answering a door the
customer had not moved from. `startBookingFrom` carries the door, carries the
destination only when asked, and never carries the window: a slot fills up, and
a second booking pinned to one that is now full is refused at confirm with
nothing the customer can act on.
Review also says why there is no "add another destination", so a cap reads as a
limit rather than a missing button.
── Bundle ──
pubspec named its images one by one. Declaring `assets/images/` as a folder
shipped a 974 KB launcher-icon master to every customer for a file no code
opens.
345 lines
16 KiB
Dart
345 lines
16 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';
|
|
|
|
// ------------------------------------------------------------------- contact
|
|
|
|
/// ── Empty on purpose ──
|
|
///
|
|
/// Account's support and policy rows used to fire a toast reading "Opening
|
|
/// doormile.com…" and open nothing. Replacing a fake toast with a fake phone
|
|
/// number is not an improvement, and a support line that rings nowhere is
|
|
/// worse than no support line — so these default to empty and every block
|
|
/// that needs one is simply absent until it is filled in.
|
|
///
|
|
/// Pass them at build time, the same way [appVersion] is passed:
|
|
///
|
|
/// --dart-define=DM_SUPPORT_PHONE=+911234567890
|
|
/// --dart-define=DM_SUPPORT_EMAIL=help@doormile.com
|
|
/// --dart-define=DM_TERMS_URL=https://doormile.com/terms
|
|
/// --dart-define=DM_PRIVACY_URL=https://doormile.com/privacy
|
|
/// --dart-define=DM_SITE_URL=https://doormile.com
|
|
static const String supportPhone = String.fromEnvironment(
|
|
'DM_SUPPORT_PHONE',
|
|
);
|
|
static const String supportEmail = String.fromEnvironment(
|
|
'DM_SUPPORT_EMAIL',
|
|
);
|
|
static const String termsUrl = String.fromEnvironment('DM_TERMS_URL');
|
|
static const String privacyUrl = String.fromEnvironment('DM_PRIVACY_URL');
|
|
static const String siteUrl = String.fromEnvironment('DM_SITE_URL');
|
|
|
|
/// True when there is at least one way for a customer to reach a person.
|
|
static bool get hasSupportContact =>
|
|
supportPhone.isNotEmpty || supportEmail.isNotEmpty;
|
|
|
|
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)' : ''}';
|
|
}
|