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:///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)' : ''}'; }