diff --git a/.gitignore b/.gitignore index e2a9806..7a80536 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,11 @@ app.*.map.json android/key.properties **/*.jks **/*.keystore + +# Gradle writes here on an Android build. `/build/` above only covers the +# repo root, so this one was being offered up as new source on every commit. +android/build/ + +# What the golden harness writes when a snapshot does not match: the actual +# render, the expected one and a diff. Useful to look at, never to keep. +test/failures/ diff --git a/integration_test/book_one_test.dart b/integration_test/book_one_test.dart index b0bf342..05636f2 100644 --- a/integration_test/book_one_test.dart +++ b/integration_test/book_one_test.dart @@ -1,89 +1,114 @@ +import 'package:doormile_cx/data/app_config.dart'; import 'package:doormile_cx/data/location_service.dart'; import 'package:doormile_cx/main.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -/// Drives the real app on the simulator all the way through a booking, then -/// holds on the confirmation so it can be photographed from outside. +/// Proves that a build carrying a real token reaches a real, authenticated +/// booking screen — on a device, against the real backend. /// -/// Run against the dev data, which is where any 4-digit code signs in and the -/// serviceability / slots / booking all exist without a server: +/// ── Why this file shrank ── +/// +/// It used to walk the whole booking: sign in with `9876543210` and four 1s, +/// then pickup → destination → window → review → confirmation. Both halves of +/// that stopped being true. +/// +/// * **The sign-in** only ever worked under the offline `DM_MOCK` build, +/// deleted on 15 Sep 2026 after it cost two rounds of hunting for bookings +/// in the admin console that had never left the phone. There is no longer a +/// four-digit code that signs anybody in. +/// * **The flow** was rebuilt into ONE screen plus two sheets. There is no +/// "Where are you sending it?" page, no "When should we come?" page and no +/// review page left to walk; the city is a horizontal strip and the window +/// is a modal sheet. +/// +/// So every finder in it referred to something that no longer exists, and it +/// could not pass. What replaces it is deliberately narrower than what it +/// claimed to do, and everything in it has been checked against the current +/// screens rather than remembered. +/// +/// **What this asserts:** the app adopts a server-issued token through the same +/// session store a normal login writes to, asks the backend who that is, opens +/// the real Home, and reaches a booking screen whose empty-draft state is +/// rendered from live serviceability. Every call in that chain is a real call. +/// +/// **What it does not assert:** the booking itself. Completing it needs a city +/// tapped out of a server-loaded strip and a window chosen from a sheet, and a +/// test that guesses at those without ever having run is worth less than an +/// honest gap. Do that part by hand on the device — the button tells you where +/// you are, in this order: +/// +/// "Pick a city to see the price" → "Choose a pickup window" → "Book pickup" +/// +/// Run it: /// /// flutter test integration_test/book_one_test.dart \ -/// -d --dart-define=DM_MOCK=true +/// -d \ +/// --dart-define=DM_DEV_TOKEN=eyJ... void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('books a pickup to Chennai and lands on the confirmation', + testWidgets('a real token opens a real, authenticated booking screen', (tester) async { - // Deterministic pin, so the pickup step never stops on an OS location - // dialog and resolves the same way every run. + // ── Skip rather than fail confusingly ── + // + // With no token the app correctly opens the login screen, and every finder + // below would fail on a missing widget — which reads as "the app is broken" + // when the truth is "this run was given no identity". Say the missing thing + // by name, once. + if (!AppConfig.authBypassed) { + markTestSkipped( + 'No DM_DEV_TOKEN (or DM_LOGIN_AS) — this test runs against the real ' + 'backend and needs a real session. Pass ' + '--dart-define=DM_DEV_TOKEN=.', + ); + return; + } + + // Deterministic pin, so the pickup row never stops on an OS location dialog + // and resolves the same way every run. LocationService.instance = FixedLocationService(); await tester.pumpWidget(const DoormileApp()); - await tester.pumpAndSettle(const Duration(milliseconds: 400)); - // The app runs continuous animations, so pumpAndSettle would spin forever. - // Pump fixed slices instead. + // The app runs continuous animations, so pumpAndSettle never returns. Pump + // fixed slices instead. Future hold([int ms = 900]) async { await tester.pump(); await tester.pump(Duration(milliseconds: ms)); await tester.pump(const Duration(milliseconds: 400)); } - // ---- sign in: 9876543210 + any 4-digit code ---------------------------- - await tester.enterText(find.byType(TextField).first, '9876543210'); - await hold(300); - await tester.tap(find.text('Continue')); - await hold(); + // Longer than the other holds on purpose: this is two network calls — the + // token is adopted into the session store, then `GET /customer/auth/me` is + // asked who it belongs to — and against a real server they are not instant. + await hold(4000); - final boxes = find.byType(TextField); - for (var i = 0; i < 4; i++) { - await tester.enterText(boxes.at(i), '1'); - await tester.pump(); - } - await hold(1200); - expect(find.text('Where should we pick up?'), findsOneWidget); - debugPrint('DRIVER: signed in, on Home'); + expect( + find.text('Where should we pick up?'), + findsOneWidget, + reason: 'the app did not reach Home. A token that is expired or revoked ' + 'lands on the login screen by design — that is the session being ' + 'refused, not a bug in the app.', + ); + debugPrint('DRIVER: real session restored from DM_DEV_TOKEN, on Home'); - // ---- BOOK → confirm the pin ----------------------------------- await tester.tap(find.text('BOOK')); - await hold(1200); - await tester.tap(find.text('Next')); - await hold(); - expect(find.text('Where are you sending it?'), findsOneWidget); + await hold(2500); - // ---- destination: Tamil Nadu ▸ Chennai --------------------------------- - await tester.tap(find.text('Tamil Nadu')); - await hold(); - await tester.tap(find.text('Chennai')); - await hold(); - expect(find.text('Chennai, Tamil Nadu'), findsOneWidget); - await tester.tap(find.text('Next')); - await hold(); + expect(find.text('Send a parcel'), findsOneWidget); - // ---- pickup window ----------------------------------------------------- - expect(find.text('When should we come?'), findsOneWidget); - await tester.tap(find.text('2:00 – 4:00 PM').first); - await hold(); - await tester.tap(find.text('Review booking')); - await hold(1200); - - // ---- review → book ----------------------------------------------------- - expect(find.text('Review pickup details'), findsOneWidget); - debugPrint('DRIVER: on review'); - await tester.tap(find.text('Book Pickup')); - await hold(1600); - - // ---- confirmation ------------------------------------------------------ - expect(find.text('Pickup booked'), findsOneWidget); - debugPrint('DRIVER: BOOKED — holding on confirmation'); - - // Hold so the confirmation can be photographed from outside the test. - for (var i = 0; i < 50; i++) { - await tester.pump(const Duration(milliseconds: 500)); - } + // The empty-draft state, in the button's own words. Reaching this proves + // the screen built against live serviceability rather than a fixture: the + // city strip is an async list off the server, and this is the label the + // screen shows while nothing in it is selected. + expect( + find.text('Pick a city to see the price'), + findsOneWidget, + reason: 'the booking screen did not reach its empty-draft state', + ); + debugPrint('DRIVER: on the booking screen, awaiting a city — ' + 'finish by hand from here'); }); } diff --git a/lib/data/app_config.dart b/lib/data/app_config.dart index bfcf4e3..f17d8f8 100644 --- a/lib/data/app_config.dart +++ b/lib/data/app_config.dart @@ -10,32 +10,72 @@ 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 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. +/// 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. /// -/// 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. +/// 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 - static const String _envName = String.fromEnvironment( - 'DM_ENV', - defaultValue: 'staging', - ); + /// 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, - _ => DoormileEnvironment.staging, + 'staging' => DoormileEnvironment.staging, + _ => kReleaseMode + ? DoormileEnvironment.prod + : DoormileEnvironment.staging, }; static bool get isProd => environment == DoormileEnvironment.prod; @@ -75,38 +115,13 @@ class AppConfig { 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, - ); + // ----------------------------------------------------------------- testing - /// True under `flutter test`, where the widget tests drive the whole journey - /// against [DevDoormileApi] and there is no server to reach. + /// 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 { @@ -116,30 +131,41 @@ class AppConfig { } } - /// 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; + // ------------------------------------------------------------- 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'); - /// Opens the app already signed in, skipping the login screen. + /// Whether this build answers from [DevDoormileApi] instead of the network. /// - /// 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. + /// 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. /// - /// 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); + /// 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 // @@ -174,8 +200,12 @@ class AppConfig { 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 => - autoLoginIdentifier.isNotEmpty && autoLoginCode.isNotEmpty; + !isTest && autoLoginIdentifier.isNotEmpty && autoLoginCode.isNotEmpty; // ------------------------------------------------------------- ops QA only @@ -194,10 +224,10 @@ class AppConfig { 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; + /// 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 // @@ -233,7 +263,7 @@ class AppConfig { /// 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; + static bool get authBypassed => hasDevToken || hasAutoLogin || devAutoLogin; // -------------------------------------------------------------- client identity @@ -271,7 +301,12 @@ class AppConfig { /// 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}' + // 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)' : ''}'; diff --git a/lib/data/dev_doormile_api.dart b/lib/data/dev_doormile_api.dart index 4748690..9a7c202 100644 --- a/lib/data/dev_doormile_api.dart +++ b/lib/data/dev_doormile_api.dart @@ -1,7 +1,7 @@ import 'dart:math'; -import 'doormile_api.dart'; -import 'models.dart'; +import 'package:doormile_cx/data/doormile_api.dart'; +import 'package:doormile_cx/data/models.dart'; /// In-memory backend for **development and tests only**. /// diff --git a/lib/data/doormile_api.dart b/lib/data/doormile_api.dart index 6ed7d96..86839bd 100644 --- a/lib/data/doormile_api.dart +++ b/lib/data/doormile_api.dart @@ -7,14 +7,24 @@ export 'api_exception.dart'; /// The service surface the app is written against. /// -/// **A shipping build has one implementation: [LiveDoormileApi],** against -/// `/customer/*` on `api.doormile.com`. When the server cannot be reached the -/// screens show their designed error state, which is the only honest answer. +/// **[LiveDoormileApi] is what ships,** against `/customer/*` on +/// `api.doormile.com`. When the server cannot be reached the screens show their +/// designed error state, which is the only honest answer. /// -/// The single exception is [DevDoormileApi], reached only when -/// [AppConfig.useDevData] is set — a `!kReleaseMode` guard, so no define swaps -/// invented data into a release. It exists because the real backend has no SMS -/// gateway, so there is otherwise no way to sign in and exercise the flow. +/// [DevDoormileApi] answers instead when [AppConfig.useDevData] is set — under +/// `FLUTTER_TEST`, or a debug build given `--dart-define=DM_MOCK=true`. That +/// getter is `false` in a release whatever is passed, so a shipped build has +/// one implementation and a booking a customer makes is one the server +/// accepted. +/// +/// ── Why this is guarded so tightly ── +/// +/// An offline build was deleted from this app on 15 Sep 2026 after it cost two +/// rounds of hunting for bookings in the admin console that had never left the +/// phone. What made that expensive was not the fake existing — it was that the +/// fake was reachable without announcing itself, and that nothing stopped it +/// reaching a release. Both are closed now: the release guard above, and the +/// Account screen printing DEV DATA (offline) the whole time it is on. /// /// Screens never see this type at all — they call [AppState], which holds it. /// @@ -25,8 +35,12 @@ abstract class DoormileApi { static DoormileApi? _instance; - /// The API this build talks to: the live backend, or [DevDoormileApi] when - /// [AppConfig.useDevData] allows it — which is never in a release build. + /// The API this build talks to. + /// + /// [LiveDoormileApi] unless the build asked for offline data, which a + /// release build cannot do — see [AppConfig.useDevData]. The check is here + /// rather than at the call sites so there is exactly one place that decides, + /// and nothing downstream has to know which one it got. static DoormileApi get instance => _instance ??= AppConfig.useDevData ? DevDoormileApi() : LiveDoormileApi(); diff --git a/lib/data/models.dart b/lib/data/models.dart index cd33a6b..a771252 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -252,6 +252,28 @@ class ServiceArea { } /// A district inside a serviceable state. +/// One serviceable city: a district, plus the state it belongs to. +/// +/// The API serves coverage as a hierarchy and the send screen offers it as one +/// flat strip, so something has to carry the state along with the district — +/// `POST /customer/bookings` wants both codes on every destination, and a +/// district code alone cannot be sent. +class CityOption { + const CityOption({required this.state, required this.district}); + + final ServiceArea state; + final District district; + + /// "Chennai, Tamil Nadu" — what the rest of the app already calls a + /// destination. + String get label => '${district.name}, ${state.name}'; + + /// The one line under the name in the strip: what this city is promised, + /// falling back to the state's transit tag when the district has no promise + /// of its own. Null when the backend told us neither. + String? get note => district.promise ?? state.transitTag; +} + class District { const District({ required this.code, diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index fbbafd3..feb95c2 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -94,22 +94,21 @@ class AppState extends ChangeNotifier { if (restored != null) { customer = restored; unawaited(refreshOrders()); + } else if (AppConfig.devAutoLogin) { + // ── Offline builds open on Home, not on a login screen ── + // + // The fake signs anybody in — there is no server to refuse them — so + // stopping at the entrance would ask for a phone number and a code + // that mean nothing, purely to arrive somewhere the build was always + // going to allow. Any 4-digit code still works if someone wants to walk + // the screen; this is the default rather than the only way through. + // + // Real builds are untouched: `useDevData` is false in every one of + // them, so the login screen a customer meets is the real one. + customer = await api.verifyOtp('+91 9876543210', '1234'); + unawaited(refreshOrders()); } else if (AppConfig.hasAutoLogin) { await _autoSignIn(); - } else if (AppConfig.devAutoLogin) { - // Dev builds only, and only when asked with DM_DEV_LOGIN: open straight - // into the app without the login screen. Guarded in [AppConfig] so no - // define can do this in a release. Without the flag, a dev build still - // lands on login, where any 4-digit code signs in. - debugPrint('[AUTH] DM_DEV_LOGIN — opening without the login screen'); - signIn( - const Customer( - id: 'cust_dev', - name: 'Dev Customer', - phone: '+91 98765 43210', - email: 'dev@doormile.com', - ), - ); } } catch (e) { // Same rule as the limits call: this decides which screen opens, so it @@ -126,23 +125,44 @@ class AppState extends ChangeNotifier { /// This is a genuine sign-in, not a bypass: it runs the same two calls the /// login and code screens run, and what it ends up holding is a token the /// server issued. That is the whole point of it existing next to - /// [AppConfig.useDevData] — a build that skips the entrance still has to end - /// up as a real customer, or every booking it makes is a fake one. + /// the deleted offline mock — a build that skips the entrance still has to + /// end up as a real customer, or every booking it makes is a fake one. /// /// Failure is not fatal and never throws: the app simply lands on the login /// screen, which is exactly where someone whose code was refused should be. Future _autoSignIn() async { final identifier = AppConfig.autoLoginIdentifier; + final code = AppConfig.autoLoginCode; + + // ── Verify first, request second ── + // + // Requesting a code is not free: with no SMS gateway registered the server + // generates a fresh one, writes it to its log and REPLACES whatever was in + // Redis. So a code someone had just read out of that log would be + // invalidated by the very call meant to use it. + // + // So this tries the code it was given first. That path is the one that + // works with a log-read code, and with a fixed `CX_STAGING_OTP` it simply + // fails once on a cold Redis and falls through to the request below. try { - // The request may come back `sent: false` on the resend cooldown; that - // is fine, the code already issued is still the one Redis is holding. - await api.sendOtp(identifier); - final verified = await api.verifyOtp(identifier, AppConfig.autoLoginCode); - customer = verified; + customer = await api.verifyOtp(identifier, code); unawaited(refreshOrders()); debugPrint('[AUTH] DM_LOGIN_AS — signed in as $identifier'); + return; + } catch (_) { + // Expected when no code has been issued for this identifier yet. + } + + try { + await api.sendOtp(identifier); + customer = await api.verifyOtp(identifier, code); + unawaited(refreshOrders()); + debugPrint('[AUTH] DM_LOGIN_AS — signed in as $identifier after a request'); } catch (e) { - debugPrint('[AUTH] DM_LOGIN_AS failed for $identifier: $e'); + debugPrint( + '[AUTH] DM_LOGIN_AS failed for $identifier: $e — ' + 'the code was refused, so the login screen opens instead', + ); } } @@ -320,7 +340,19 @@ class AppState extends ChangeNotifier { /// The in-progress pickup. One destination is the common case; the customer /// can add more, all collected in the same visit. - Place? draftPickup; + /// + /// A setter rather than a field, because the map editor writes it and then + /// pops: as a plain field the send screen underneath kept its last frame and + /// went on showing the address the customer had just corrected. + Place? get draftPickup => _draftPickup; + set draftPickup(Place? place) { + if (_draftPickup == place) return; + _draftPickup = place; + notifyListeners(); + } + + Place? _draftPickup; + List draftDestinations = [DestinationGroup()]; String? draftSlotId; FareEstimate? draftFare; @@ -695,6 +727,43 @@ class AppState extends ChangeNotifier { return all.where((d) => d.available).toList(); } + /// Every serviceable city, flat, for the send screen's city strip. + /// + /// ── Why this exists rather than a state picker ── + /// + /// The serviceability API is a hierarchy — states, then districts — because + /// that is how coverage is administered. It is not how anyone thinks about + /// sending a parcel: nobody picks "Tamil Nadu" on the way to picking + /// "Chennai", and on a network this size the state step is a screen that asks + /// a question with one useful answer. + /// + /// So the hierarchy is flattened here, once, into the list the screen wants. + /// The districts come back in parallel — five states is five calls, not a + /// waterfall — and each city carries its state so [selectCity] can set both + /// codes the booking contract needs. + Future> loadCities({bool refresh = false}) async { + if (refresh) { + statesCache = null; + districtCache.clear(); + } + final states = await loadStates(); + final lists = await Future.wait([ + for (final state in states) loadDistricts(state.code), + ]); + return [ + for (var i = 0; i < states.length; i++) + for (final district in lists[i]) + CityOption(state: states[i], district: district), + ]; + } + + /// Picks a city on the draft's only destination — both codes at once, since + /// the contract wants `stateCode` and `districtCode` together. + void selectCity(CityOption city, {int index = 0}) { + selectStateFor(index, city.state); + selectDistrictFor(index, city.district); + } + /// Names of districts in this state that are not open yet, for a quiet /// "Coming soon" line. Empty until the districts have been fetched. List upcomingDistricts(String stateCode) => (districtCache[stateCode] ?? const []) diff --git a/lib/ui/screens/auth/login_screen.dart b/lib/ui/screens/auth/login_screen.dart index d2db505..39f743b 100644 --- a/lib/ui/screens/auth/login_screen.dart +++ b/lib/ui/screens/auth/login_screen.dart @@ -1,12 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../data/doormile_api.dart'; import '../../../state/app_scope.dart'; import '../../widgets/buttons.dart'; import '../../widgets/feedback.dart'; import '../../widgets/inputs.dart'; -import '../../widgets/pieces.dart'; import 'auth_scaffold.dart'; import 'otp_screen.dart'; import 'signup_screen.dart'; @@ -95,24 +93,30 @@ class _LoginScreenState extends State { @override Widget build(BuildContext context) { return AuthScaffold( - // The promise sits on the crimson, at display size, and the sheet gets - // on with the form. A "Welcome back" heading in the sheet said nothing - // the screen did not already say, and pushed the first control down. - heroTitle: 'Book a pickup from your door.', - heroSub: 'Track every parcel through to delivery.', + // ── The screen asks one question ── + // + // This 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 — + // they already downloaded it. Uber, Bolt and Porter all open the same + // way: a line telling you what to type, the field, the button. The + // marketing copy moves to where marketing belongs, which is not in front + // of a customer trying to get back into their account. + // + // So the banner compacts to the lockup, the badge goes, and the heading + // becomes an instruction rather than a promise. + compactBanner: true, + badge: false, + title: _isPhone ? 'Enter your mobile number' : 'Enter your email address', // The CTA is pinned, not scrolled. In the scroll area it was clipped by // the footer the moment the keyboard came up — the one control the screen // exists for, hidden exactly when it is needed. footer: Column( mainAxisSize: MainAxisSize.min, children: [ - DmButton( - label: 'Continue', - busy: _sending, - busyLabel: 'Sending code…', - onPressed: _valid ? _continue : null, - ), - const SizedBox(height: 10), AuthSwitchLink( question: 'New to Doormile?', action: 'Create account', @@ -125,45 +129,70 @@ class _LoginScreenState extends State { ], ), children: [ - // The same chip the Orders filter and the address labels use. One - // selection control across the app means the customer learns it once, - // on the screen they meet first. - Row( - children: [ - for (final mode in LoginMode.values) ...[ - if (mode != LoginMode.values.first) const SizedBox(width: 8), - DmChoiceChip( - label: mode == LoginMode.phone ? 'Phone' : 'Email', - selected: _mode == mode, - expand: true, - onTap: () => setState(() { - _mode = mode; - _identifier.clear(); - }), - ), - ], - ], - ), - const SizedBox(height: 22), + // No label above it. The heading already said what to type, and a + // field captioned "Phone number" under a heading reading "Enter your + // mobile number" is the same sentence twice. DmTextField( - label: _isPhone ? 'Phone number' : 'Email address', controller: _identifier, hint: _isPhone ? '98765 43210' : 'you@example.com', prefix: _isPhone ? '+91' : null, - keyboardType: _isPhone ? TextInputType.phone : TextInputType.emailAddress, + keyboardType: _isPhone + ? TextInputType.phone + : TextInputType.emailAddress, maxLength: _isPhone ? 10 : 60, digitsOnly: _isPhone, + autofocus: true, textInputAction: TextInputAction.done, onSubmitted: (_) => _valid ? _continue() : null, onClear: _identifier.text.isEmpty ? null : () => _identifier.clear(), ), - // Sits under the field it explains rather than floating below the - // button, where it read as a fourth thing to deal with. - const DmInfoBanner( - icon: LucideIcons.shieldCheck, - title: 'Your number stays private', - message: "We'll send a 4-digit code to keep your parcels secure.", + + // ── The button belongs under the field, not at the foot of the page ── + // + // It was pinned to the bottom, and with the marketing copy and the + // privacy card gone there was nothing left to fill the space between — + // half a screen of nothing, with the field at the top and the button + // stranded at the bottom. + // + // Pinning solved a real problem: in the scroll area it was clipped by + // the footer when the keyboard came up. But that was a crowded screen. + // This one is a heading, a field and a button, all of which fit above + // the keyboard together — which is exactly where Uber, Bolt and Porter + // put them. The footer keeps only what the customer is not acting on. + const SizedBox(height: 18), + DmButton( + label: 'Continue', + busy: _sending, + busyLabel: 'Sending code…', + onPressed: _valid ? _continue : null, ), + + // ── The other way in, offered rather than asked ── + // + // Phone and Email were two chips at the top of the screen, which made + // choosing a method the first decision — before the customer had seen + // what was being asked for. Almost everyone uses the phone; the toggle + // taxed all of them to serve a few. + // + // It is one quiet line under the field now, in the place these apps + // put it, and it reads as a way out rather than a fork. + const SizedBox(height: 14), + Center( + child: DmTextAction( + label: _isPhone ? 'Use email instead' : 'Use mobile number instead', + onPressed: () => setState(() { + _mode = _isPhone ? LoginMode.email : LoginMode.phone; + _identifier.clear(); + }), + ), + ), + + // The privacy card that stood here is gone. It explained that a + // 4-digit code would be sent — which the next screen demonstrates a + // second later — and asserted that a number "stays private", which is + // a claim the privacy policy makes properly and a card cannot. A + // reassurance nobody asked for reads as something to be reassured + // about. ], ); } diff --git a/lib/ui/screens/booking/booking_routes.dart b/lib/ui/screens/booking/booking_routes.dart index 3148764..68047dc 100644 --- a/lib/ui/screens/booking/booking_routes.dart +++ b/lib/ui/screens/booking/booking_routes.dart @@ -1,16 +1,15 @@ import 'package:flutter/material.dart'; -/// Named steps in the booking flow. +/// Named destinations in the booking flow. /// -/// Naming the routes lets Review's "Edit" actions pop straight back to the -/// step being corrected instead of stacking a second copy of it. +/// The flow is one screen plus two escape hatches, so this is shorter than it +/// was: [send] is the booking, [pickup] is the map-pin editor behind "Change", +/// and the rest are where you land afterwards. class BookingRoutes { BookingRoutes._(); + static const send = 'booking/send'; static const pickup = 'booking/pickup'; - static const destination = 'booking/destination'; - static const slot = 'booking/slot'; - static const review = 'booking/review'; static const confirmed = 'booking/confirmed'; static const tracking = 'tracking'; } @@ -22,22 +21,3 @@ Route bookingRoute(String name, Widget Function(BuildContext) builder) { builder: builder, ); } - -/// Returns to an earlier step if it is still on the stack, otherwise pushes a -/// fresh copy of it. The navigator is captured up front because popping -/// unmounts the calling screen. -void editStep( - BuildContext context, - String name, - Widget Function(BuildContext) builder, -) { - final navigator = Navigator.of(context); - var found = false; - navigator.popUntil((route) { - if (route.settings.name == name) found = true; - return found || route.isFirst; - }); - if (!found) { - navigator.push(bookingRoute(name, builder)); - } -} diff --git a/lib/ui/screens/booking/confirmed_screen.dart b/lib/ui/screens/booking/confirmed_screen.dart index b01b032..16518b4 100644 --- a/lib/ui/screens/booking/confirmed_screen.dart +++ b/lib/ui/screens/booking/confirmed_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -import '../../../data/app_config.dart'; import '../../../state/app_scope.dart'; import '../../tokens.dart'; import '../../widgets/buttons.dart'; @@ -220,24 +219,12 @@ class _ConfirmedScreenState extends State ], ), const SizedBox(height: 10), - // The one screen where a fake booking is most convincing, so it - // is the one screen that has to admit it. - if (AppConfig.useDevData) - const DmInfoBanner( - icon: LucideIcons.wifiOff, - tone: DmTagTone.warn, - title: 'Offline dev data — nothing was sent', - message: 'This build is not talking to a server, so this ' - 'booking exists only on this phone and will not appear ' - 'in the admin console.', - ) - else - const DmInfoBanner( - icon: LucideIcons.package, - tone: DmTagTone.neutral, - title: 'Keep the parcel ready', - message: 'Sealed and labelled with the reference above.', - ), + const DmInfoBanner( + icon: LucideIcons.package, + tone: DmTagTone.neutral, + title: 'Keep the parcel ready', + message: 'Sealed and labelled with the reference above.', + ), ], ], ), diff --git a/lib/ui/screens/booking/destination_screen.dart b/lib/ui/screens/booking/destination_screen.dart deleted file mode 100644 index abafe6b..0000000 --- a/lib/ui/screens/booking/destination_screen.dart +++ /dev/null @@ -1,406 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; - -import '../../../data/models.dart'; -import '../../../state/app_scope.dart'; -import '../../tokens.dart'; -import '../../widgets/buttons.dart'; -import '../../widgets/cards.dart'; -import '../../widgets/chrome.dart'; -import '../../widgets/pieces.dart'; -import '../../widgets/states.dart'; -import 'booking_routes.dart'; -import 'slot_screen.dart'; - -/// Step 2 — where the packages are going. -/// -/// Opens as a single destination, which is the common case: pick a state, pick -/// a district, done. Package count is never asked for — it keeps its model -/// default of 1 — and sending to a second place is a secondary action that only -/// appears once the first one is settled. A customer with one package to one -/// district never sees the multi-destination machinery. -class DestinationScreen extends StatefulWidget { - const DestinationScreen({super.key}); - - @override - State createState() => _DestinationScreenState(); -} - -class _DestinationScreenState extends State { - /// Which block is open for editing. Null when every block is settled, so - /// coming back from Review shows the chosen places rather than re-opening - /// the picker. - int? _editing; - - @override - void initState() { - super.initState(); - final groups = AppScope.read(context).draftDestinations; - final firstIncomplete = groups.indexWhere((g) => !g.isComplete); - _editing = firstIncomplete == -1 ? null : firstIncomplete; - } - - @override - Widget build(BuildContext context) { - final app = AppScope.of(context); - final groups = app.draftDestinations; - final limits = app.limits; - - return Scaffold( - backgroundColor: DmColors.canvas, - appBar: const DmTopBar(title: 'Schedule pickup'), - body: Column( - children: [ - const DmStepHeader(step: 2, total: 4, label: 'Destination'), - Expanded( - child: ListView( - padding: const EdgeInsets.fromLTRB( - DmSpace.pad, - 0, - DmSpace.pad, - 28, - ), - children: [ - // The question is the instruction. The line under it said - // "Choose from the areas we currently serve", which is what the - // list below already demonstrates by only containing those - // areas — a caption for a control that captions itself. - Text('Where are you sending it?', style: DmText.title), - const SizedBox(height: 20), - - for (var i = 0; i < groups.length; i++) ...[ - _DestinationBlock( - index: i, - group: groups[i], - editing: _editing == i || !groups[i].isComplete, - showRemove: groups.length > 1, - onEdit: () => setState(() => _editing = i), - onSettled: () => setState(() => _editing = null), - onRemove: () { - app.removeDestination(i); - setState(() => _editing = null); - }, - ), - const SizedBox(height: 12), - ], - - // Offered only where the backend allows more than one place. - // While `maxDestinations` is 1 there is nothing to explain and - // nothing to disable — a control that can never be used is - // better absent than permanently greyed. - if (app.allowsMultipleDestinations) - _AddDestination( - enabled: app.canAddDestination, - reason: _capReason(app, limits), - onTap: () { - if (app.addDestination()) { - setState( - () => _editing = app.draftDestinations.length - 1, - ); - } - }, - ), - ], - ), - ), - ], - ), - bottomNavigationBar: DmFooter( - edge: true, - children: [ - DmButton( - label: 'Next', - icon: LucideIcons.arrowRight, - onPressed: app.draftReady - ? () => Navigator.of(context).push( - bookingRoute( - BookingRoutes.slot, - (_) => const SlotScreen(), - ), - ) - : null, - ), - ], - ), - ); - } - - /// Friendly, non-alarming explanation when a cap blocks another place. - String? _capReason(dynamic app, BookingLimits limits) { - if (app.canAddDestination as bool) return null; - // Mid-selection is not a problem to explain — the action simply is not - // offered yet. - if (!(app.draftReady as bool)) return null; - if ((app.draftDestinations as List).length >= limits.maxDestinations) { - return 'Up to ${limits.maxDestinations} places in one pickup. ' - 'Book another pickup for more.'; - } - return 'Up to ${limits.maxPackages} packages in one pickup.'; - } -} - -/// One place: state, district, how many packages, optional details. -class _DestinationBlock extends StatelessWidget { - const _DestinationBlock({ - required this.index, - required this.group, - required this.editing, - required this.showRemove, - required this.onEdit, - required this.onSettled, - required this.onRemove, - }); - - final int index; - final DestinationGroup group; - final bool editing; - final bool showRemove; - final VoidCallback onEdit; - final VoidCallback onSettled; - final VoidCallback onRemove; - - @override - Widget build(BuildContext context) { - return AnimatedSize( - duration: DmMotion.base, - curve: DmMotion.ease, - alignment: Alignment.topCenter, - child: editing - ? _Picker(index: index, group: group, onSettled: onSettled) - : _Settled( - index: index, - group: group, - showRemove: showRemove, - onEdit: onEdit, - onRemove: onRemove, - ), - ); - } -} - -/// Choose the state, then the districts open beneath it. -class _Picker extends StatelessWidget { - const _Picker({ - required this.index, - required this.group, - required this.onSettled, - }); - - final int index; - final DestinationGroup group; - final VoidCallback onSettled; - - @override - Widget build(BuildContext context) { - final app = AppScope.of(context); - final destination = group.destination; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const DmMicroHead('Sending to', top: 0, bottom: 10), - DmAsyncList( - skeletonRows: 1, - load: ({bool refresh = false}) => app.loadStates(refresh: refresh), - emptyTitle: 'No service areas available', - emptyMessage: "We're not accepting new pickups right now. Please check back shortly.", - builder: (context, states) => SizedBox( - height: 42, - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: EdgeInsets.zero, - itemCount: states.length, - separatorBuilder: (_, _) => const SizedBox(width: 10), - itemBuilder: (context, i) => DmChoiceChip( - label: states[i].name, - selected: destination.stateCode == states[i].code, - onTap: () => app.selectStateFor(index, states[i]), - ), - ), - ), - ), - if (destination.stateCode != null) ...[ - const SizedBox(height: 18), - const DmMicroHead('District', top: 0, bottom: 10), - DmAsyncList( - key: ValueKey('districts-${destination.stateCode}'), - skeletonRows: 3, - load: ({bool refresh = false}) => - app.loadDistricts(destination.stateCode!, refresh: refresh), - emptyTitle: 'No districts open here yet', - emptyMessage: - "We're not delivering inside ${destination.stateName} right now. " - 'Pick another state above.', - emptyActionLabel: 'Got it', - onEmptyAction: () {}, - builder: (context, districts) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: EdgeInsets.zero, - itemCount: districts.length, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - // The cell grows with the reader's text size rather than - // clipping the district name. - // One per row as soon as the reader's text grows, so the - // place name always has room. - crossAxisCount: - MediaQuery.textScalerOf(context).scale(1) > 1.15 - ? 1 - : 2, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - mainAxisExtent: MediaQuery.textScalerOf(context).scale(70), - ), - itemBuilder: (context, i) { - final d = districts[i]; - return DmGridTile( - // Just the place. The delivery promise appears on the - // settled card, where it actually informs a decision. - label: d.name, - selected: destination.districtCode == d.code, - onTap: () { - app.selectDistrictFor(index, d); - onSettled(); - }, - ); - }, - ), - // Places we are opening are named once, quietly — never as a - // grid of tiles the customer cannot use. - if (app.upcomingDistricts(destination.stateCode!).isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 12, left: 2, right: 2), - child: Text( - 'Coming soon: ' - '${app.upcomingDistricts(destination.stateCode!).join(', ')}', - style: DmText.small.copyWith(fontSize: 12.5), - ), - ), - ], - ), - ), - ], - ], - ); - } -} - -/// A chosen place, collapsed to a card with its package count. -class _Settled extends StatelessWidget { - const _Settled({ - required this.index, - required this.group, - required this.showRemove, - required this.onEdit, - required this.onRemove, - }); - - final int index; - final DestinationGroup group; - final bool showRemove; - final VoidCallback onEdit; - final VoidCallback onRemove; - - @override - Widget build(BuildContext context) { - return DmCard( - tone: DmCardTone.wash, - children: [ - DmCardRow( - tone: DmCardTone.wash, - onTap: onEdit, - leading: const DmTile( - icon: LucideIcons.mapPin, - tone: DmTileTone.wash, - ), - // Numbered only when there is more than one to tell apart. - eyebrow: showRemove ? 'Destination ${index + 1}' : 'Sending to', - title: group.destination.label, - subtitle: group.district?.promise, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - DmTextAction(label: 'Change', onPressed: onEdit), - if (showRemove) - IconButton( - onPressed: onRemove, - icon: const Icon(LucideIcons.x, size: 17), - color: DmColors.ink3, - tooltip: 'Remove this destination', - visualDensity: VisualDensity.compact, - ), - ], - ), - ), - // ── Two rows removed: package count, and exact delivery details ── - // - // A destination block was a card containing a divider, a stepper in - // its own row, another divider, and a row that opened a bottom sheet - // for optional detail — four decisions stacked under an address the - // customer had already given. Almost every booking is one package to - // one door, so the common path was paying for two controls it never - // touched. - // - // `packageCount` keeps its model default and the read-only screens - // still show it; what is gone is asking. The delivery-details sheet - // is deleted outright — see `sheets/`. - ], - ); - } -} - -/// The quiet secondary action that unlocks multi-destination pickups. -class _AddDestination extends StatelessWidget { - const _AddDestination({ - required this.enabled, - required this.reason, - required this.onTap, - }); - - final bool enabled; - final String? reason; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - if (!enabled) { - return reason == null - ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), - child: Text(reason!, style: DmText.small), - ); - } - - return InkWell( - onTap: onTap, - borderRadius: DmRadius.all(DmRadius.xs), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon( - LucideIcons.circlePlus, - size: 19, - color: DmColors.brand, - ), - const SizedBox(width: 6), - Flexible( - child: Text( - 'Send to another place', - style: DmText.label.copyWith( - fontSize: 15, - color: DmColors.brand, - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/ui/screens/booking/pickup_location_screen.dart b/lib/ui/screens/booking/pickup_location_screen.dart index 0911d2c..fe47b66 100644 --- a/lib/ui/screens/booking/pickup_location_screen.dart +++ b/lib/ui/screens/booking/pickup_location_screen.dart @@ -10,10 +10,7 @@ import '../../widgets/cards.dart'; import '../../widgets/chrome.dart'; import '../../widgets/feedback.dart'; import '../../widgets/map_panel.dart'; -import '../../widgets/pieces.dart'; import '../sheets/place_search_sheet.dart'; -import 'booking_routes.dart'; -import 'destination_screen.dart'; /// Step 1 — where we collect from. /// @@ -60,10 +57,9 @@ class _PickupLocationScreenState extends State { return Scaffold( backgroundColor: DmColors.canvas, - appBar: const DmTopBar(title: 'Schedule pickup'), + appBar: const DmTopBar(title: 'Pickup point'), body: Column( children: [ - const DmStepHeader(step: 1, total: 4, label: 'Pickup point'), const Padding( padding: EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 0), child: _Intro(), @@ -143,20 +139,15 @@ class _PickupLocationScreenState extends State { edge: true, children: [ DmButton( - label: 'Next', - icon: LucideIcons.arrowRight, + label: 'Use this address', + icon: LucideIcons.check, // Gated on the pin, not just the address: a booking without // coordinates is one the Miler cannot be routed to. onPressed: !app.hasPickupPoint || resolving ? null : () { app.draftPickup = place; - Navigator.of(context).push( - bookingRoute( - BookingRoutes.destination, - (_) => const DestinationScreen(), - ), - ); + Navigator.of(context).maybePop(); }, ), ], diff --git a/lib/ui/screens/booking/review_screen.dart b/lib/ui/screens/booking/review_screen.dart deleted file mode 100644 index 89c8311..0000000 --- a/lib/ui/screens/booking/review_screen.dart +++ /dev/null @@ -1,377 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; -import 'package:flutter/services.dart'; - -import '../../../data/doormile_api.dart'; -import '../../../data/models.dart'; -import '../../../state/app_scope.dart'; -import '../../tokens.dart'; -import '../../widgets/buttons.dart'; -import '../../widgets/cards.dart'; -import '../../widgets/chrome.dart'; -import '../../widgets/feedback.dart'; -import '../../widgets/pieces.dart'; -import '../../widgets/route_map.dart'; -import '../../widgets/route_rail.dart'; -import '../../widgets/summary.dart'; -import '../sheets/place_search_sheet.dart'; -import 'booking_routes.dart'; -import 'confirmed_screen.dart'; -import 'destination_screen.dart'; -import 'slot_screen.dart'; - -/// Step 4 — review. -/// -/// Reads the same whether the pickup carries one package to one district or -/// six across three. Nothing is charged here: the fare is indicative until the -/// Miler verifies the packages at the door. -class ReviewScreen extends StatefulWidget { - const ReviewScreen({super.key}); - - @override - State createState() => _ReviewScreenState(); -} - -class _ReviewScreenState extends State { - bool _busy = false; - - Future _confirm() async { - setState(() => _busy = true); - final app = AppScope.read(context); - final navigator = Navigator.of(context); - try { - await app.confirmBooking(); - unawaited(HapticFeedback.mediumImpact()); - navigator.pushAndRemoveUntil( - bookingRoute(BookingRoutes.confirmed, (_) => const ConfirmedScreen()), - (route) => route.isFirst, - ); - } on ApiException catch (e) { - if (!mounted) return; - setState(() => _busy = false); - DmToast.show(context, e.message); - // A slot that filled up, or whose window passed while this screen was - // open, is not something the customer can fix from Review — the only - // move is to pick again. `confirmBooking` has already dropped the stale - // list, so going back re-reads it. - if (e.needsFreshSlots) { - navigator.popUntil( - (route) => route.settings.name == BookingRoutes.slot || route.isFirst, - ); - } - } - } - - @override - Widget build(BuildContext context) { - final app = AppScope.of(context); - final pickup = app.draftPickup ?? app.pickup; - final slot = app.slotById(app.draftSlotId); - final groups = app.draftDestinations; - final multi = groups.length > 1; - final packages = app.draftTotalPackages; - final fare = app.draftFare; - - return Scaffold( - backgroundColor: DmColors.canvas, - appBar: const DmTopBar(title: 'Schedule pickup'), - body: Column( - children: [ - const DmStepHeader(step: 4, total: 4, label: 'Review'), - Expanded( - child: ListView( - padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28), - children: [ - // "Pay nothing now" belongs beside the amount, where the - // summary already states it — not as a paragraph under the - // heading that has to be read before the thing it describes. - Text('Review pickup details', style: DmText.title), - const SizedBox(height: 18), - DmCard( - children: [ - DmRouteMap( - height: 178, - radius: 0, - pickup: pickup, - destination: groups.first.hub, - pickupLabel: pickup?.title, - destinationLabel: multi - ? '${groups.length} destinations' - : groups.first.destination.districtName, - badge: _MapBadge( - icon: multi ? LucideIcons.route : LucideIcons.arrowRight, - label: multi - ? '${groups.length} destinations' - : 'Door to door', - ), - ), - ], - ), - const SizedBox(height: 10), - DmSummaryCard( - children: [ - // One journey, one rail: pickup connected to everywhere it - // is going, exactly as an order card reads. - _RouteSection( - pickup: pickup, - groups: groups, - onEditPickup: () async { - final picked = await showPlaceSearchSheet(context); - if (picked != null && context.mounted) { - app.setPickup(picked); - app.draftPickup = picked; - } - }, - onChangeDestinations: () => editStep( - context, - BookingRoutes.destination, - (_) => const DestinationScreen(), - ), - ), - DmSummaryRow( - label: 'Pickup time', - value: slot?.label ?? '—', - actionLabel: 'Change', - onAction: () => editStep( - context, - BookingRoutes.slot, - (_) => const SlotScreen(), - ), - ), - ], - ), - if (multi) ...[ - const SizedBox(height: 10), - Row( - children: [ - const Icon( - LucideIcons.package, - size: 17, - color: DmColors.ink3, - ), - const SizedBox(width: 8), - Flexible( - child: Text( - '$packages packages · ${groups.length} destinations', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: DmText.label.copyWith(fontSize: 14), - ), - ), - const SizedBox(width: 10), - Flexible( - child: Text( - 'One Miler visit', - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, - style: DmText.small, - ), - ), - ], - ), - ], - const SizedBox(height: 10), - DmNote( - child: Text.rich( - TextSpan( - style: DmText.small.copyWith(color: DmColors.ink2, height: 1.5), - children: [ - const TextSpan(text: 'This books '), - const TextSpan( - text: 'one Miler visit', - style: TextStyle( - fontWeight: FontWeight.w700, - color: DmColors.ink, - ), - ), - TextSpan( - text: '. Tracking is created once your packages are ' - 'collected, and cancelling is free until then. ' - 'The amount below covers ', - ), - TextSpan( - text: '$packages ' - '${packages == 1 ? 'package' : 'packages'} and is ' - 'confirmed after the Miler verifies them at your ' - 'door.', - ), - ], - ), - ), - ), - ], - ), - ), - ], - ), - bottomNavigationBar: DmFooter( - edge: true, - children: [ - // The price sits directly above the button that agrees to it. It - // used to live in a card halfway up the page, which meant the last - // thing on screen before "Book Pickup" could easily be a note about - // cancellation rather than the amount. - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text('Estimated total', style: DmText.small), - // The method is its own line: appended to the label it - // made one long string that truncated mid-word, and the - // customer reads the two facts separately anyway. - if (fare?.paymentMethod != null) - Text( - fare!.paymentMethod!, - style: DmText.tiny.copyWith(fontSize: 12), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - const SizedBox(width: 12), - Text( - fare?.range ?? 'Calculating…', - style: DmText.monoLg.copyWith(fontSize: 20, color: DmColors.brand), - ), - ], - ), - ), - DmButton( - label: 'Book pickup', - icon: LucideIcons.arrowRight, - busy: _busy, - busyLabel: 'Booking…', - onPressed: app.draftReady ? _confirm : null, - ), - DmButton( - label: 'Edit details', - kind: DmButtonKind.ghost, - onPressed: _busy ? null : () => Navigator.of(context).maybePop(), - ), - ], - ), - ); - } -} - -/// Pickup and every destination on one rail, with the two edit actions. -class _RouteSection extends StatelessWidget { - const _RouteSection({ - required this.pickup, - required this.groups, - required this.onEditPickup, - required this.onChangeDestinations, - }); - - final Place? pickup; - final List groups; - final VoidCallback onEditPickup; - final VoidCallback onChangeDestinations; - - bool get _anyMissingDetails => groups.any( - (g) => g.details.exactAddress == null && g.details.recipient == null, - ); - - @override - Widget build(BuildContext context) { - final multi = groups.length > 1; - - return Padding( - padding: const EdgeInsets.fromLTRB(16, 15, 16, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DmRouteRail( - pickupEyebrow: 'Pickup', - pickup: pickup?.title ?? '—', - pickupNote: pickup?.sub, - pickupTrailing: DmTextAction( - label: 'Edit', - onPressed: onEditPickup, - minHeight: 32, - ), - dropsEyebrow: 'Sending to', - drops: [ - for (final group in groups) - DmRailStop( - label: group.destination.label, - trailing: - '${group.packageCount} ' - '${group.packageCount == 1 ? 'package' : 'packages'}', - note: multi - ? null - : (group.details.exactAddress ?? - (group.details.recipient == null - ? 'Not added — the Miler can confirm this at pickup.' - : 'Recipient · ${group.details.recipient}')), - ), - ], - ), - // With several places, say it once rather than per line. - if (multi && _anyMissingDetails) - Padding( - padding: const EdgeInsets.only(top: 8, left: 30), - child: Text( - 'Exact addresses not added — the Miler can confirm these at pickup.', - style: DmText.small.copyWith(fontSize: 12.5), - ), - ), - Align( - alignment: Alignment.centerRight, - child: DmTextAction( - label: multi ? 'Change destinations' : 'Change destination', - onPressed: onChangeDestinations, - minHeight: 36, - ), - ), - ], - ), - ); - } -} - -class _MapBadge extends StatelessWidget { - const _MapBadge({required this.icon, required this.label}); - - final IconData icon; - final String label; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.fromLTRB(10, 7, 12, 7), - decoration: BoxDecoration( - color: DmColors.surface, - border: Border.all(color: DmColors.border), - borderRadius: DmRadius.all(6), - boxShadow: DmShadow.float, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: DmColors.brand), - const SizedBox(width: 6), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: DmText.label.copyWith(fontSize: 12, color: DmColors.ink), - ), - ), - ], - ), - ); - } -} diff --git a/lib/ui/screens/booking/send_screen.dart b/lib/ui/screens/booking/send_screen.dart new file mode 100644 index 0000000..fe9432a --- /dev/null +++ b/lib/ui/screens/booking/send_screen.dart @@ -0,0 +1,506 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../data/doormile_api.dart'; +import '../../../data/models.dart'; +import '../../../state/app_scope.dart'; +import '../../../state/app_state.dart'; +import '../../tokens.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/cards.dart'; +import '../../widgets/chrome.dart'; +import '../../widgets/feedback.dart'; +import '../../widgets/inputs.dart'; +import '../../widgets/pieces.dart'; +import '../../widgets/states.dart'; +import '../../widgets/summary.dart'; +import '../sheets/window_sheet.dart'; +import 'booking_routes.dart'; +import 'confirmed_screen.dart'; +import 'pickup_location_screen.dart'; + +/// Send a parcel — the whole booking, on one screen. +/// +/// ── Why this replaced four screens ── +/// +/// Booking used to be pickup point → destination → window → review: four page +/// turns, three of which asked one question each, and a review step whose job +/// was to show you what you had just typed on the three screens before it. The +/// customer could not see what the send would cost until screen four, and could +/// not see the shape of what they were arranging at all. +/// +/// It is one screen now. Everything the contract needs is here — where it is +/// going, the street address, who receives it, the window, how many packages — +/// and the price sits in the footer beside the button that agrees to it. The +/// two escape hatches stay as they were: the map pin editor for the pickup +/// point, and the window sheet for a slot other than the first. +/// +/// The state hierarchy is flattened on the way in (see [AppState.loadCities]): +/// nobody picks "Tamil Nadu" on the way to picking "Chennai". +class SendScreen extends StatefulWidget { + const SendScreen({super.key}); + + @override + State createState() => _SendScreenState(); +} + +class _SendScreenState extends State { + final _building = TextEditingController(); + final _street = TextEditingController(); + final _landmark = TextEditingController(); + final _recipient = TextEditingController(); + final _phone = TextEditingController(); + + bool _busy = false; + + /// Whether the optional block — landmark and recipient — is open. + /// + /// Starts closed, and deliberately stays closed on a screen the customer + /// comes back to: reopening it because something is filled in would undo the + /// point of folding it away, and the summary row already says what is there. + bool _showMore = false; + + /// How many of the three optional fields have something in them. + int _filledOptional() => [ + _landmark, + _recipient, + _phone, + ].where((c) => c.text.trim().isNotEmpty).length; + + /// The one row that stands in for three fields. + /// + /// Counts rather than lists, because the count is what a customer scrolling + /// past needs — "is anything in there?" — and the labels are one tap away. + String _optionalSummary() { + final n = _filledOptional(); + if (n == 0) return 'Add landmark or recipient details'; + return '$n of 3 extra details added'; + } + + @override + void dispose() { + for (final c in [_building, _street, _landmark, _recipient, _phone]) { + c.dispose(); + } + super.dispose(); + } + + /// Everything the customer typed, onto the draft, in one move. + /// + /// Written at book time rather than on every keystroke: none of it changes + /// the price or unlocks the button, so rebuilding the screen as someone types + /// their street name would be work with nothing to show for it. + void _commitDetails(AppState app) { + app.draftDestinations.first.details + ..building = _clean(_building.text) + ..street = _clean(_street.text) + ..landmark = _clean(_landmark.text) + ..recipientName = _clean(_recipient.text) + ..recipientPhone = _phone.text.trim().isEmpty + ? null + : '+91 ${_phone.text.trim()}'; + } + + static String? _clean(String v) { + final t = v.trim(); + return t.isEmpty ? null : t; + } + + Future _book(AppState app) async { + _commitDetails(app); + setState(() => _busy = true); + final navigator = Navigator.of(context); + try { + await app.confirmBooking(); + unawaited(HapticFeedback.mediumImpact()); + navigator.pushAndRemoveUntil( + bookingRoute(BookingRoutes.confirmed, (_) => const ConfirmedScreen()), + (route) => route.isFirst, + ); + } on ApiException catch (e) { + if (!mounted) return; + setState(() => _busy = false); + DmToast.show(context, e.message); + // A window that filled up, or whose time passed while this screen was + // open, is not something the customer can fix from here — the only move + // is to pick again, so open the sheet on the fresh list rather than + // making them find it. + if (e.needsFreshSlots && mounted) { + await showWindowSheet(context); + } + } + } + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context); + final pickup = app.draftPickup ?? app.pickup; + final group = app.draftDestinations.first; + final city = group.destination; + final slot = app.slotById(app.draftSlotId); + final fare = app.draftFare; + + final hasCity = city.districtCode != null; + final ready = hasCity && app.draftSlotId != null && app.draftReady; + + return Scaffold( + backgroundColor: DmColors.canvas, + appBar: const DmTopBar(title: 'Send a parcel'), + body: ListView( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28), + children: [ + // Where it comes from, settled before this screen opened. + DmCard( + tone: DmCardTone.wash, + children: [ + DmCardRow( + tone: DmCardTone.wash, + leading: const DmTile( + icon: LucideIcons.mapPin, + tone: DmTileTone.wash, + ), + eyebrow: 'Collecting from', + title: pickup?.title ?? 'Set a pickup address', + subtitle: pickup?.sub, + trailing: DmTextAction( + label: 'Change', + onPressed: () => Navigator.of(context).push( + bookingRoute( + BookingRoutes.pickup, + (_) => const PickupLocationScreen(), + ), + ), + ), + ), + ], + ), + + const DmMicroHead('Sending to', top: 18), + _CityStrip( + selectedCode: city.districtCode, + onPick: (picked) { + app.selectCity(picked); + // The window is worth nothing without a destination and is + // almost always the first one, so it is chosen here rather than + // asked for — the row below says which, and stays changeable. + unawaited(_ensureSlot(app)); + }, + ), + + // ── Neutral from here down ── + // + // This head was crimson, and so were "Collecting from", "Sending to" + // and "Recipient" — four accent heads on one screen, above a wash + // card that is itself tinted. An accent used five times is not an + // accent; it flattens into decoration and stops pointing at anything. + // Crimson now marks one thing on this screen — the destination, which + // is the only choice that changes the price — and everything + // structural is quiet. + const DmMicroHead('Street address', top: 18, brand: false), + DmTextField( + label: 'Flat or house number', + controller: _building, + hint: '12B, Nehru Apartments', + textInputAction: TextInputAction.next, + ), + DmTextField( + label: 'Street and area', + controller: _street, + hint: 'Anna Nagar West, 3rd Street', + textInputAction: TextInputAction.next, + ), + + // ── The optional half, folded away ── + // + // Landmark, recipient name and recipient phone are all optional, and + // all three were drawn at exactly the weight of the two fields that + // are not. That put six rows of "you may skip this" between the + // address and the button, so the common path — city, flat, street, + // book — scrolled past everything it did not need in order to reach + // the thing it did. + // + // They are one row now. It counts what has been filled in rather than + // just saying "optional", because a customer who has added a phone + // number and scrolled away needs to see that it is still there + // without opening anything. + const SizedBox(height: 14), + DmCard( + children: [ + DmCardRow( + leading: Icon( + _showMore + ? LucideIcons.chevronDown + : LucideIcons.chevronRight, + size: 16, + color: DmColors.ink3, + ), + title: _optionalSummary(), + titleStyle: DmText.cardTitle.copyWith( + color: _filledOptional() == 0 ? DmColors.ink3 : null, + ), + onTap: () => setState(() => _showMore = !_showMore), + ), + ], + ), + + if (_showMore) ...[ + const SizedBox(height: 4), + DmTextField( + label: 'Landmark', + optional: true, + controller: _landmark, + hint: 'Near the bus stand', + textInputAction: TextInputAction.next, + ), + DmTextField( + label: 'Recipient name', + optional: true, + controller: _recipient, + hint: 'Meera Anand', + keyboardType: TextInputType.name, + textInputAction: TextInputAction.next, + ), + DmTextField( + label: 'Recipient phone', + optional: true, + controller: _phone, + hint: '90031 44518', + prefix: '+91', + keyboardType: TextInputType.phone, + digitsOnly: true, + maxLength: 10, + mono: true, + textInputAction: TextInputAction.done, + ), + ], + + const SizedBox(height: 10), + // The two facts that are already decided, as rows rather than steps. + DmCard( + children: [ + DmCardRow( + leading: const Icon( + LucideIcons.clock, + size: 16, + color: DmColors.ink3, + ), + title: slot == null ? 'Choose a pickup window' : slot.label, + titleStyle: slot == null + ? DmText.cardTitle.copyWith(color: DmColors.ink3) + : null, + trailing: DmTextAction( + label: slot == null ? 'Choose' : 'Change', + onPressed: () async { + await showWindowSheet(context); + }, + ), + ), + DmCardRow( + leading: const Icon( + LucideIcons.package, + size: 16, + color: DmColors.ink3, + ), + title: + '${group.packageCount} ' + '${group.packageCount == 1 ? 'package' : 'packages'}', + trailing: DmStepper( + value: group.packageCount, + min: 1, + canIncrease: app.canAddPackageTo(0), + onChanged: (v) => app.setPackageCount(0, v), + ), + ), + ], + ), + + const SizedBox(height: 12), + DmNote( + child: Text( + 'This books one Miler visit. Nothing is charged now — the final ' + 'amount is confirmed after the Miler weighs your parcel at the ' + 'door, and cancelling is free until then.', + style: DmText.small.copyWith(height: 1.5), + ), + ), + ], + ), + bottomNavigationBar: DmFooter( + edge: true, + children: [ + // The price sits directly above the button that agrees to it. + if (ready && fare != null) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded(child: Text('Estimated total', style: DmText.small)), + const SizedBox(width: 12), + Text( + fare.range, + style: DmText.monoLg.copyWith( + fontSize: 20, + color: DmColors.brand, + ), + ), + ], + ), + ), + DmButton( + label: switch (true) { + _ when !hasCity => 'Pick a city to see the price', + _ when app.draftSlotId == null => 'Choose a pickup window', + _ => 'Book pickup', + }, + icon: ready ? LucideIcons.arrowRight : null, + busy: _busy, + busyLabel: 'Booking…', + onPressed: ready && !_busy ? () => _book(app) : null, + ), + ], + ), + ); + } + + /// Picks the first window the backend still has, if none is chosen yet. + Future _ensureSlot(AppState app) async { + if (app.draftSlotId != null) return; + try { + final slots = await app.loadSlots(); + final first = slots.where((s) => s.available).firstOrNull; + if (first != null && mounted && app.draftSlotId == null) { + app.selectSlot(first); + } + } on ApiException { + // No windows is a state the row already renders; it is not an error to + // interrupt someone's typing over. + } + } +} + +/// The serviceable cities, as one horizontal strip. +class _CityStrip extends StatelessWidget { + const _CityStrip({required this.selectedCode, required this.onPick}); + + final String? selectedCode; + final ValueChanged onPick; + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context); + + // The height belongs to the LIST, not to the section: an empty or errored + // strip renders a full-size state with a retry, and boxing that into 104pt + // overflowed it by 227. + return DmAsyncList( + key: const ValueKey('cityStrip'), + skeletonRows: 1, + load: ({bool refresh = false}) => app.loadCities(refresh: refresh), + emptyIcon: LucideIcons.mapPinOff, + emptyTitle: 'No cities open yet', + emptyMessage: + "We're not accepting new pickups right now. Please check back " + 'shortly.', + builder: (context, cities) => SizedBox( + // Grows with the type inside it. The card holds a glyph, a city and a + // promise; at 1.8x accessibility text a fixed 104 clipped the promise + // by four points. + height: 104 + + (MediaQuery.textScalerOf(context).scale(13.5) - 13.5) * 2, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: EdgeInsets.zero, + itemCount: cities.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, i) => _CityCard( + city: cities[i], + selected: cities[i].district.code == selectedCode, + onTap: () => onPick(cities[i]), + ), + ), + ), + ); + } +} + +class _CityCard extends StatelessWidget { + const _CityCard({ + required this.city, + required this.selected, + required this.onTap, + }); + + final CityOption city; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + selected: selected, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + HapticFeedback.selectionClick(); + onTap(); + }, + child: AnimatedContainer( + duration: DmMotion.base, + curve: DmMotion.ease, + // Wide enough for the promise under the name — "Next-day delivery" + // ellipsized on every card at 110, which made the one fact that + // distinguishes two cities unreadable on both of them. + width: 132, + padding: const EdgeInsets.fromLTRB(11, 12, 11, 10), + decoration: BoxDecoration( + // A wash and a crimson edge rather than a fill: a strip of these is + // scanned side by side, and one solid block pulls the eye off the + // neighbours still being compared. + color: selected ? DmColors.brandSoft : DmColors.surface, + border: Border.all( + color: selected ? DmColors.brand : DmColors.border, + ), + borderRadius: DmRadius.all(DmRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Icon( + selected ? LucideIcons.mapPin : LucideIcons.building2, + size: 22, + color: selected ? DmColors.brand : DmColors.ink3, + ), + const Spacer(), + Text( + city.district.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.cardTitle.copyWith( + fontSize: 13.5, + color: selected ? DmColors.brand : DmColors.ink, + ), + ), + if (city.note != null) + Text( + city.note!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.tiny.copyWith( + fontSize: 11, + color: selected ? DmColors.brandPress : DmColors.ink3, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/screens/booking/slot_screen.dart b/lib/ui/screens/booking/slot_screen.dart deleted file mode 100644 index b4321ac..0000000 --- a/lib/ui/screens/booking/slot_screen.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; - -import '../../../data/models.dart'; -import '../../../state/app_scope.dart'; -import '../../tokens.dart'; -import '../../widgets/buttons.dart'; -import '../../widgets/cards.dart'; -import '../../widgets/chrome.dart'; -import '../../widgets/feedback.dart'; -import '../../widgets/option_tile.dart'; -import '../../widgets/pieces.dart'; -import '../../widgets/states.dart'; -import '../sheets/place_search_sheet.dart'; -import 'booking_routes.dart'; -import 'review_screen.dart'; - -/// Step 2 — Pickup timing. -/// -/// Confirms where we are collecting from and which window the customer wants, -/// showing only what the backend still has capacity for. -class SlotScreen extends StatefulWidget { - const SlotScreen({super.key}); - - @override - State createState() => _SlotScreenState(); -} - -class _SlotScreenState extends State { - @override - Widget build(BuildContext context) { - final app = AppScope.of(context); - final pickup = app.draftPickup ?? app.pickup; - - return Scaffold( - backgroundColor: DmColors.canvas, - appBar: const DmTopBar(title: 'Schedule pickup'), - body: Column( - children: [ - const DmStepHeader(step: 3, total: 4, label: 'Pickup time'), - Expanded( - child: ListView( - padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28), - children: [ - // Reassurance, not instruction: "A verified Miler arrives at - // your door inside the window you pick" restated the heading, - // the product, and the list of windows underneath it. The - // customer is here to pick a time. - Text('When should we come?', style: DmText.title), - const SizedBox(height: 20), - - // ── Where we are collecting from ── - // - // A wash card, because it is brand-owned context rather than a - // field: the customer already chose this address on the screen - // before, and it is here to be confirmed at a glance, not - // re-entered. Change is the only control on it. - DmCard( - tone: DmCardTone.wash, - children: [ - DmCardRow( - tone: DmCardTone.wash, - leading: const DmTile( - icon: LucideIcons.mapPin, - tone: DmTileTone.wash, - ), - eyebrow: 'Collecting from', - title: pickup?.title ?? 'Locating you…', - subtitle: pickup?.sub, - trailing: DmTextAction( - label: 'Change', - onPressed: () async { - final picked = await showPlaceSearchSheet(context); - if (picked != null && context.mounted) { - app.setPickup(picked); - app.draftPickup = picked; - DmToast.show(context, 'Pickup location updated'); - } - }, - ), - ), - ], - ), - const SizedBox(height: 22), - - // ---- available windows -------------------------------------- - // One eyebrow, not two. "Local time (IST)" sat opposite this - // one explaining a timezone the customer is standing in. - const DmMicroHead('Available windows', top: 0, bottom: 10), - DmAsyncList( - // Re-reads whenever the slot list is invalidated — starting a - // booking, or the server refusing a window that has passed. - reloadToken: app.slotsEpoch, - load: ({bool refresh = false}) => app.loadSlots(refresh: refresh), - emptyIcon: LucideIcons.clock, - emptyTitle: 'No pickup slots left today', - emptyMessage: - 'All windows are full right now. Try again in a little while.', - builder: (context, slots) { - final days = []; - for (final slot in slots) { - if (!days.contains(slot.day)) days.add(slot.day); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final day in days) ...[ - Padding( - padding: const EdgeInsets.only(bottom: 10, top: 4), - child: Text(day, style: DmText.sectionHead), - ), - for (final slot in slots.where((s) => s.day == day)) - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: _SlotCard( - slot: slot, - selected: app.draftSlotId == slot.id, - onTap: () => app.selectSlot(slot), - ), - ), - const SizedBox(height: 10), - ], - ], - ); - }, - ), - - ], - ), - ), - ], - ), - bottomNavigationBar: DmFooter( - edge: true, - children: [ - DmButton( - label: 'Review booking', - icon: LucideIcons.arrowRight, - onPressed: app.draftSlotId == null - ? null - : () { - Navigator.of(context).push( - bookingRoute( - BookingRoutes.review, - (_) => const ReviewScreen(), - ), - ); - }, - ), - ], - ), - ); - } -} - -/// One window. -/// -/// Selection **fills** here (see [DmOptionTile]): on a list of five windows a -/// tinted card was too quiet to answer "which one did I pick?" at a glance, -/// and this is the last decision before the customer commits to a price. -class _SlotCard extends StatelessWidget { - const _SlotCard({required this.slot, required this.selected, required this.onTap}); - - final PickupSlot slot; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final enabled = slot.available; - - return DmOptionTile( - title: slot.window, - // The time is the thing being chosen, so it is set in the display face - // rather than at row weight. - titleStyle: DmText.display.copyWith(fontSize: 19, height: 1.2), - subtitle: enabled ? slot.caption : (slot.note ?? 'Unavailable'), - selected: selected, - enabled: enabled, - onTap: onTap, - tags: [ - if (slot.tag != null) - DmTag( - slot.tag!, - tone: selected ? DmTagTone.onFill : DmTagTone.brand, - icon: LucideIcons.zap, - ), - if (slot.milersNearby > 0) - DmTag( - '${slot.milersNearby} Milers nearby', - tone: selected ? DmTagTone.onFill : DmTagTone.neutral, - icon: LucideIcons.userRound, - ), - ], - // A full window offers the only thing left to do with it. - trailing: enabled - ? null - : DmTextAction( - label: 'Notify me', - color: DmColors.ink3, - onPressed: () => - DmToast.show(context, "We'll let you know if it frees up"), - ), - ); - } -} diff --git a/lib/ui/screens/home_screen.dart b/lib/ui/screens/home_screen.dart index e2dadae..2cb5d4b 100644 --- a/lib/ui/screens/home_screen.dart +++ b/lib/ui/screens/home_screen.dart @@ -11,7 +11,7 @@ import '../widgets/chrome.dart'; import '../widgets/misc.dart'; import '../widgets/route_rail.dart'; import 'booking/booking_routes.dart'; -import 'booking/pickup_location_screen.dart'; +import 'booking/send_screen.dart'; import 'shell_screen.dart'; import 'sheets/place_search_sheet.dart'; import 'tracking_screen.dart'; @@ -32,10 +32,7 @@ class HomeScreen extends StatelessWidget { void startBooking() { AppScope.read(context).startBooking(); Navigator.of(context).push( - bookingRoute( - BookingRoutes.pickup, - (_) => const PickupLocationScreen(), - ), + bookingRoute(BookingRoutes.send, (_) => const SendScreen()), ); } diff --git a/lib/ui/screens/sheets/window_sheet.dart b/lib/ui/screens/sheets/window_sheet.dart new file mode 100644 index 0000000..e7b1b9a --- /dev/null +++ b/lib/ui/screens/sheets/window_sheet.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../data/models.dart'; +import '../../../state/app_scope.dart'; +import '../../tokens.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/feedback.dart'; +import '../../widgets/option_tile.dart'; +import '../../widgets/pieces.dart'; +import '../../widgets/states.dart'; + +/// Pickup window. +/// +/// ── Why a sheet and not a step ── +/// +/// Choosing a two-hour window used to be its own screen in a four-screen flow, +/// which put a full page turn between "where is it going" and "what will it +/// cost". It is one choice from a short list, and the answer is almost always +/// the first row — so it belongs over the send screen, not in front of it. +/// +/// The send screen shows the chosen window inline and only opens this when the +/// customer wants a different one. +Future showWindowSheet(BuildContext context) { + return showDmSheet( + context: context, + builder: (context) => const _WindowSheet(), + ); +} + +class _WindowSheet extends StatefulWidget { + const _WindowSheet(); + + @override + State<_WindowSheet> createState() => _WindowSheetState(); +} + +class _WindowSheetState extends State<_WindowSheet> { + String? _day; + PickupSlot? _picked; + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: DmSheetHeader( + title: 'Pickup window', + subtitle: (app.draftPickup ?? app.pickup) == null + ? null + : 'Collecting from ${(app.draftPickup ?? app.pickup)!.title}', + ), + ), + DmIconButton( + icon: LucideIcons.x, + tooltip: 'Close', + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + Flexible( + child: DmAsyncList( + reloadToken: app.slotsEpoch, + load: ({bool refresh = false}) => app.loadSlots(refresh: refresh), + emptyIcon: LucideIcons.clock, + emptyTitle: 'No pickup windows left', + emptyMessage: + 'Every window is full right now. Try again in a little while.', + builder: (context, slots) { + final days = []; + for (final slot in slots) { + if (!days.contains(slot.day)) days.add(slot.day); + } + final day = days.contains(_day) ? _day! : days.first; + final shown = slots.where((s) => s.day == day).toList(); + final selected = _picked ?? _slotById(slots, app.draftSlotId); + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // One row of days rather than a heading per day: the list + // under it stays short enough to take in at a glance, which + // is the whole reason this is a sheet. + Row( + children: [ + for (final d in days) ...[ + if (d != days.first) const SizedBox(width: 8), + DmChoiceChip( + label: d, + selected: d == day, + expand: true, + onTap: () => setState(() => _day = d), + ), + ], + ], + ), + const SizedBox(height: 16), + for (final slot in shown) ...[ + _SlotRow( + slot: slot, + selected: selected?.id == slot.id, + onTap: () => setState(() => _picked = slot), + ), + const SizedBox(height: 10), + ], + const SizedBox(height: 6), + DmButton( + label: selected == null + ? 'Choose a window' + : 'Use ${selected.window}', + onPressed: selected == null + ? null + : () { + app.selectSlot(selected); + Navigator.of(context).pop(selected); + }, + ), + const SizedBox(height: 4), + ], + ), + ); + }, + ), + ), + ], + ); + } + + static PickupSlot? _slotById(List slots, String? id) { + if (id == null) return null; + for (final slot in slots) { + if (slot.id == id) return slot; + } + return null; + } +} + +class _SlotRow extends StatelessWidget { + const _SlotRow({ + required this.slot, + required this.selected, + required this.onTap, + }); + + final PickupSlot slot; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final enabled = slot.available; + + return DmOptionTile( + title: slot.window, + titleStyle: DmText.display.copyWith(fontSize: 19, height: 1.2), + subtitle: enabled ? slot.caption : (slot.note ?? 'Unavailable'), + selected: selected, + enabled: enabled, + onTap: onTap, + tags: [ + if (slot.tag != null) + DmTag( + slot.tag!, + tone: selected ? DmTagTone.onFill : DmTagTone.brand, + icon: LucideIcons.zap, + ), + if (slot.milersNearby > 0) + DmTag( + '${slot.milersNearby} Milers nearby', + tone: selected ? DmTagTone.onFill : DmTagTone.neutral, + icon: LucideIcons.userRound, + ), + ], + trailing: enabled + ? null + : DmTextAction( + label: 'Notify me', + color: DmColors.ink3, + onPressed: () => + DmToast.show(context, "We'll let you know if it frees up"), + ), + ); + } +} diff --git a/lib/ui/screens/tracking_screen.dart b/lib/ui/screens/tracking_screen.dart index b234058..cab4ede 100644 --- a/lib/ui/screens/tracking_screen.dart +++ b/lib/ui/screens/tracking_screen.dart @@ -18,7 +18,7 @@ import '../widgets/route_rail.dart'; import '../widgets/states.dart'; import '../widgets/summary.dart'; import 'booking/booking_routes.dart'; -import 'booking/pickup_location_screen.dart'; +import 'booking/send_screen.dart'; import 'sheets/cancel_sheet.dart'; /// Live tracking. @@ -242,10 +242,7 @@ class TrackingScreen extends StatelessWidget { onPressed: () { app.startBooking(); Navigator.of(context).pushAndRemoveUntil( - bookingRoute( - BookingRoutes.pickup, - (_) => const PickupLocationScreen(), - ), + bookingRoute(BookingRoutes.send, (_) => const SendScreen()), (route) => route.isFirst, ); }, diff --git a/lib/ui/widgets/chrome.dart b/lib/ui/widgets/chrome.dart index b585f20..5b488de 100644 --- a/lib/ui/widgets/chrome.dart +++ b/lib/ui/widgets/chrome.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -import '../../data/app_config.dart'; import '../tokens.dart'; import 'buttons.dart'; @@ -335,30 +334,6 @@ class DmBrandHeader extends StatelessWidget { children: [ ?leading, const Spacer(), - // ── A build on dev data says so, on every screen ── - // - // Twice now a booking made offline has been hunted for - // in the admin console. The Account screen named the - // mode, but nobody goes to Account before booking. It - // belongs where the booking is made. - if (AppConfig.useDevData) ...[ - Container( - padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), - decoration: BoxDecoration( - border: Border.all(color: const Color(0x80FFFFFF)), - borderRadius: DmRadius.all(5), - ), - child: Text( - 'OFFLINE DATA', - style: DmText.eyebrow.copyWith( - fontSize: 10, - color: Colors.white, - letterSpacing: 1.2, - ), - ), - ), - const SizedBox(width: 10), - ], ?action, ], ), diff --git a/lib/ui/widgets/inputs.dart b/lib/ui/widgets/inputs.dart index 35adffd..d87ea60 100644 --- a/lib/ui/widgets/inputs.dart +++ b/lib/ui/widgets/inputs.dart @@ -18,7 +18,7 @@ import 'cards.dart'; class DmTextField extends StatefulWidget { const DmTextField({ super.key, - required this.label, + this.label, required this.controller, this.hint, this.optional = false, @@ -34,7 +34,12 @@ class DmTextField extends StatefulWidget { this.onClear, }); - final String label; + /// The caption above the field, or null for a field that stands alone. + /// + /// Nullable because a screen whose heading already says what to type does + /// not need to say it again directly underneath — "Enter your mobile number" + /// above a field captioned "Phone number" is one sentence printed twice. + final String? label; final TextEditingController controller; final String? hint; final bool optional; @@ -85,23 +90,25 @@ class _DmTextFieldState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text.rich( - TextSpan( - style: DmText.label, - children: [ - TextSpan(text: widget.label), - if (widget.optional) - const TextSpan( - text: ' Optional', - style: TextStyle( - color: DmColors.ink4, - fontWeight: FontWeight.w500, + if (widget.label != null) ...[ + Text.rich( + TextSpan( + style: DmText.label, + children: [ + TextSpan(text: widget.label), + if (widget.optional) + const TextSpan( + text: ' Optional', + style: TextStyle( + color: DmColors.ink4, + fontWeight: FontWeight.w500, + ), ), - ), - ], + ], + ), ), - ), - const SizedBox(height: 8), + const SizedBox(height: 8), + ], AnimatedContainer( duration: DmMotion.fast, curve: DmMotion.ease, diff --git a/test/api_integration_test.dart b/test/api_integration_test.dart index 28fb541..75f8d6e 100644 --- a/test/api_integration_test.dart +++ b/test/api_integration_test.dart @@ -222,6 +222,45 @@ void main() { }); }); + group('which backend this build believes it is', () { + // `DM_ENV` used to default to `staging` for EVERY build, release included, + // while `_stagingBase` is `_prodBase`. So a release APK built without + // `--dart-define=DM_ENV=prod` talked to production and reported + // `isProd == false` — and `isProd` is what the non-network guards read: the + // Account screen's Build panel, `allowStageOverride`, `setStage`'s refusal, + // and the environment name on every bug report. + + test('an unflagged debug build is staging, not production', () { + // This test run carries no DM_ENV, and under test kReleaseMode is false. + expect(AppConfig.environment, DoormileEnvironment.staging); + expect(AppConfig.isProd, isFalse); + }); + + test('staging and prod resolve to the same host today', () { + // The backend has not named a staging host, so the environment decides + // what the build CALLS ITSELF, not who it talks to. Worth pinning: the + // day a real staging host appears, this test failing is the reminder that + // the default now changes where traffic goes. + expect(AppConfig.baseUrl, 'https://api.doormile.com/api/v1'); + }); + + test('the environment is named on every build description', () { + // What a bug report carries. A production session that called itself + // "staging" is how a real incident gets triaged against the wrong server. + expect(AppConfig.describe, contains(AppConfig.environment.name)); + }); + + // NOT ASSERTABLE FROM HERE, and stated rather than faked: the release + // branch of `environment` is `kReleaseMode ? prod : staging`, and + // kReleaseMode is a compile-time const that is false under `flutter test`. + // There is no way to exercise the release arm from a debug test run, so + // this note is the coverage. Verify it with: + // + // flutter build apk --release → Account shows no Build panel + // flutter build apk --release --dart-define=DM_ENV=staging + // → Build panel returns + }); + group('development access', () { test('no dev token is compiled into this build', () { // A pasted token is not present unless a define supplies one. @@ -229,33 +268,45 @@ void main() { expect(AppConfig.describe, isNot(contains('DEV TOKEN'))); }); - test('the test environment is a bypass, and says so out loud', () { - // The Miler app shipped a flag that swapped authentication for a fake - // rider on any four digits, and said nothing on screen. The rule that - // replaces it: a build that did not sign in must name it. Under - // FLUTTER_TEST the dev data is on, so it must read as bypassed and the - // environment line must carry the DEV DATA marker. - expect(AppConfig.useDevData, isTrue, reason: 'dev data is on under test'); - expect(AppConfig.authBypassed, isTrue); - expect(AppConfig.describe, contains('DEV DATA')); + test('an offline build says so, every time', () { + // The offline build is back, and this is the guarantee that makes it + // safe to have. It was deleted once because a build answering from the + // fake looked exactly like a build answering from the server — which + // cost two rounds of hunting for bookings in the admin console that had + // never left the phone. + // + // A test run IS an offline build, so this asserts the announcement in + // the one place it can be asserted at all. + expect(AppConfig.useDevData, isTrue); + expect(AppConfig.describe, contains('DEV DATA (offline)')); + expect( + AppConfig.describe, + isNot(contains(AppConfig.baseUrl)), + reason: 'naming a host a build never contacts is the confusion this ' + 'whole guard exists to prevent', + ); }); - test('the auto-login default never reaches the tests', () { - // Dev data now defaults on in a debug build, and with it the login - // screen is skipped — one `flutter run` lands on Home, because staging - // has no SMS gateway to send a code through. - // - // The widget tests must keep walking that entrance anyway: they cover the - // phone field, the code screen, and the guard that refuses to open the - // code screen when nothing was sent. A session waiting for them at launch - // would delete all of it and nothing would fail, so [devAutoLogin] is - // switched off under FLUTTER_TEST on purpose. This is that promise. - expect(AppConfig.devAutoLogin, isFalse); + test('no dev token and no auto-login are compiled in', () { + // The two ways to skip the login SCREEN without faking the login. Both + // hold a server-issued token; neither define is passed to this run. + expect(AppConfig.hasAutoLogin, isFalse); + expect(AppConfig.hasDevToken, isFalse); + }); + + test('the auto-login flag can never fire under 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. A session + // waiting for them at launch would delete that coverage and nothing + // would fail, so the flag is inert under FLUTTER_TEST whatever the + // defines say. + expect(AppConfig.isTest, isTrue); + expect(AppConfig.hasAutoLogin, isFalse); }); // The guarantee that matters cannot be asserted from a debug test — every - // switch is `!kReleaseMode`, so `useDevData`, `devAutoLogin`, `hasDevToken` - // and `allowStageOverride` are all forced false in a release build, + // switch is `!kReleaseMode`, so `autoLoginIdentifier`, `hasDevToken` and + // `allowStageOverride` are all forced empty/false in a release build, // whatever the defines say. That is what keeps a bypass out of the store. }); diff --git a/test/booking_flow_test.dart b/test/booking_flow_test.dart index ea4756c..87ef7c5 100644 --- a/test/booking_flow_test.dart +++ b/test/booking_flow_test.dart @@ -9,6 +9,7 @@ import 'package:doormile_cx/ui/widgets/buttons.dart'; import 'package:doormile_cx/ui/widgets/map_panel.dart'; import 'package:doormile_cx/ui/widgets/chrome.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:doormile_cx/data/dev_doormile_api.dart'; @@ -77,37 +78,40 @@ Future serverAdvance( await settle(tester, 1400); } -/// Home ▸ BOOK ▸ confirm the pin ▸ destination step. -Future openDestination(WidgetTester tester) async { +/// Home ▸ Start booking ▸ the send screen. +/// +/// One screen now: the pickup point is already known, so there is no step +/// between the CTA and the form. +Future openSend(WidgetTester tester) async { await tester.tap(find.text('Start booking')); await settle(tester); - await tester.tap(find.text('Next')); - await settle(tester); } -/// State chips scroll horizontally; drag later ones fully into view first. -Future tapState(WidgetTester tester, String name) async { +/// Opens the pickup-point editor from the send screen's "Change". +Future openPickupEditor(WidgetTester tester) async { + await openSend(tester); + await tester.tap(find.text('Change').first); + await settle(tester); +} + +/// The city strip scrolls horizontally; drag later cities into view first. +Future pickCity(WidgetTester tester, String name) async { final width = tester.view.physicalSize.width / tester.view.devicePixelRatio; - final chips = find - .ancestor(of: find.text('Tamil Nadu'), matching: find.byType(Scrollable)) - .first; + final strip = find.descendant( + of: find.byKey(const ValueKey('cityStrip')), + matching: find.byType(Scrollable), + ); for (var i = 0; i < 8; i++) { final finder = find.text(name); if (finder.evaluate().isNotEmpty) { - final rect = tester.getRect(finder); + final rect = tester.getRect(finder.first); if (rect.left >= 0 && rect.right <= width) break; } - await tester.drag(chips, const Offset(-160, 0)); + await tester.drag(strip, const Offset(-160, 0)); await tester.pump(); } - await tester.tap(find.text(name)); - await settle(tester); -} - -Future pickPlace(WidgetTester tester, String state, String district) async { - await tapState(tester, state); - await tester.tap(find.text(district)); + await tester.tap(find.text(name).first); await settle(tester); } @@ -144,8 +148,7 @@ void main() { testWidgets('dragging the map re-resolves the pickup address', (tester) async { await signIn(tester); - await tester.tap(find.text('Start booking')); - await settle(tester); + await openPickupEditor(tester); // The pin resolved to a street, not a placeholder. expect(find.text('Getting address…'), findsNothing); @@ -164,10 +167,12 @@ void main() { expect(find.text('Getting address…'), findsNothing); expect(pickupAddress(tester), isNot(address)); - // Which is what Next now carries forward. - await tester.tap(find.text('Next')); + // Which is what the editor carries back to the send screen. + final corrected = pickupAddress(tester); + await tester.tap(find.text('Use this address')); await settle(tester); - expect(find.text('Where are you sending it?'), findsOneWidget); + expect(find.text('Send a parcel'), findsOneWidget); + expect(find.text(corrected), findsOneWidget); await drainToasts(tester); }); @@ -177,8 +182,7 @@ void main() { location: FixedLocationService(lat: 11.0183, lng: 76.9725), ); await signIn(tester, state: state); - await tester.tap(find.text('Start booking')); - await settle(tester); + await openPickupEditor(tester); DmMapPanel map() => tester.widget(find.byType(DmMapPanel)); @@ -214,9 +218,9 @@ void main() { expect(pickupAddress(tester), 'Brookefields Mall'); // …and that one point is what the booking carries forward. - await tester.tap(find.text('Next')); + await tester.tap(find.text('Use this address')); await settle(tester); - expect(find.text('Where are you sending it?'), findsOneWidget); + expect(find.text('Send a parcel'), findsOneWidget); expect(state.draftPickup!.lat, searched.lat); expect(state.draftPickup!.lng, searched.lng); expect(state.draftPickup!.title, 'Brookefields Mall'); @@ -228,8 +232,7 @@ void main() { location: FixedLocationService(lat: 11.0183, lng: 76.9725), ); await signIn(tester, state: state); - await tester.tap(find.text('Start booking')); - await settle(tester); + await openPickupEditor(tester); // Whatever the pin resolves to, it reads as a place — never a decimal pair. final coordinate = RegExp(r'-?\d{1,3}\.\d{3,}'); @@ -256,8 +259,7 @@ void main() { ), ); await signIn(tester, state: state); - await tester.tap(find.text('Start booking')); - await settle(tester); + await openPickupEditor(tester); final dot = find.byKey(const ValueKey('deviceLocationDot')); expect(dot, findsOneWidget); @@ -284,8 +286,7 @@ void main() { final state = AppState(); await signIn(tester, state: state); - await tester.tap(find.text('Start booking')); - await settle(tester); + await openPickupEditor(tester); // A dot in the wrong place is worse than no dot. expect(find.byKey(const ValueKey('deviceLocationDot')), findsNothing); @@ -299,8 +300,7 @@ void main() { FixedLocationService.denied(LocationDenial.denied); await signIn(tester); - await tester.tap(find.text('Start booking')); - await settle(tester); + await openPickupEditor(tester); // The map falls back to the service area rather than an empty ocean, and // the refusal is explained instead of silently swallowed. @@ -312,52 +312,42 @@ void main() { // Booking is not blocked by it. expect( - tester.widget(find.widgetWithText(DmButton, 'Next')).onPressed, + tester + .widget( + find.widgetWithText(DmButton, 'Use this address'), + ) + .onPressed, isNotNull, ); await drainToasts(tester); }); - testWidgets('one destination, one package, every optional field skipped', + testWidgets('one screen books a parcel, every optional field skipped', (tester) async { final state = AppState(); await signIn(tester, state: state); - await openDestination(tester); - expect(find.text('Where are you sending it?'), findsOneWidget); - - await pickPlace(tester, 'Tamil Nadu', 'Chennai'); - - // Settles into one card carrying the place and nothing else demanded. - // - // The package stepper and the optional delivery-details row were removed: - // one package to one door is the whole of the common path, so the step now - // asks for the destination and stops. `packageCount` still defaults to 1 in - // the model, which the read-only screens go on showing. - expect(find.text('Chennai, Tamil Nadu'), findsOneWidget); - expect(find.text('How many packages?'), findsNothing); - expect(find.text('Add exact delivery details'), findsNothing); - expect(find.text('Send to another place'), findsOneWidget); - - await tester.tap(find.text('Next')); - await settle(tester); - expect(find.text('When should we come?'), findsOneWidget); - - await tester.tap(find.text('2:00 – 4:00 PM').first); - await tester.pump(); - await tester.tap(find.text('Review booking')); - await settle(tester); - - expect(find.text('Review pickup details'), findsOneWidget); + await openSend(tester); + // Everything the booking needs is on this one screen. + expect(find.text('Send a parcel'), findsOneWidget); expect(find.text('SENDING TO'), findsOneWidget); - expect(find.text('1 package'), findsOneWidget); - // Optional omissions are stated calmly, never as an error. - expect( - find.text('Not added — the Miler can confirm this at pickup.'), - findsOneWidget, - ); - // A single destination shows no multi-destination summary. - expect(find.textContaining('destinations'), findsNothing); + expect(find.text('STREET ADDRESS'), findsOneWidget); + // RECIPIENT is no longer a head of its own. Landmark and the two recipient + // fields are optional, and drawn at full weight they put six rows of + // "you may skip this" between the address and the button — so they fold + // behind one row, and this is the closed state the screen opens in. + expect(find.text('Add landmark or recipient details'), findsOneWidget); + expect(find.text('Landmark'), findsNothing); + + // Nothing to price until somewhere to send it. + expect(find.text('Pick a city to see the price'), findsOneWidget); + + await pickCity(tester, 'Chennai'); + + // Picking the city also takes the first window the backend still has, so + // the common path never opens the sheet. + expect(state.draftSlotId, isNotNull); + expect(find.text('Book pickup'), findsOneWidget); await tester.tap(find.text('Book pickup')); await settle(tester); @@ -367,180 +357,112 @@ void main() { expect(booking.destinations, hasLength(1)); expect(booking.totalPackages, 1); expect(booking.destinations.first.trackingId, isNull); + // Skipped fields are absent, not empty strings. + expect(booking.details.exactAddress, isNull); await drainToasts(tester); }); - // ── These three used to drive the package stepper ── - // - // The stepper is gone from the destination step: one package to one door is - // the whole common path, so the screen no longer asks. The *logic* it drove - // is untouched — `setPackageCount` still clamps to `limits.maxPackages`, the - // totals still roll up, and the booking payload still carries them — so the - // counts are set on the state here and the assertions that matter (totals, - // the cap, what review and the booking show) are kept exactly as they were. - testWidgets('one destination, four packages', (tester) async { + testWidgets('the address and recipient reach the booking', (tester) async { final state = AppState(); await signIn(tester, state: state); - await openDestination(tester); - await pickPlace(tester, 'Tamil Nadu', 'Coimbatore'); + await openSend(tester); + await pickCity(tester, 'Chennai'); - state.setPackageCount(0, 4); - await tester.pump(); - expect(state.draftTotalPackages, 4); + await tester.enterText( + find.widgetWithText(TextField, '12B, Nehru Apartments'), + '12B, Nehru Apartments', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Anna Nagar West, 3rd Street'), + 'Anna Nagar West', + ); + // The recipient fields are optional and now fold behind one row, so a + // customer who wants them opens that first. This tap is the flow, not a + // workaround for it. + await tester.tap(find.text('Add landmark or recipient details')); + await settle(tester, 200); - await tester.tap(find.text('Next')); - await settle(tester); - await tester.tap(find.text('2:00 – 4:00 PM').first); - await tester.pump(); - await tester.tap(find.text('Review booking')); - await settle(tester); - - expect(find.text('4 packages'), findsOneWidget); - await drainToasts(tester); - }); - - testWidgets('three destinations with mixed counts total correctly on review', - (tester) async { - final state = AppState(); - await signIn(tester, state: state); - - await openDestination(tester); - await pickPlace(tester, 'Tamil Nadu', 'Chennai'); - state.setPackageCount(0, 2); - await tester.pump(); - - await tester.tap(find.text('Send to another place')); - await settle(tester); - await pickPlace(tester, 'Kerala', 'Ernakulam'); - - await tester.tap(find.text('Send to another place')); - await settle(tester); - await pickPlace(tester, 'Karnataka', 'Bengaluru Urban'); - state.setPackageCount(2, 3); - await tester.pump(); - - expect(state.draftDestinations, hasLength(3)); - expect(state.draftTotalPackages, 6); - - await tester.tap(find.text('Next')); - await settle(tester); - await tester.tap(find.text('2:00 – 4:00 PM').first); - await tester.pump(); - await tester.tap(find.text('Review booking')); - await settle(tester); - - expect(find.text('6 packages · 3 destinations'), findsOneWidget); - expect(find.text('Chennai, Tamil Nadu'), findsOneWidget); - expect(find.text('Ernakulam, Kerala'), findsOneWidget); - expect(find.text('Bengaluru Urban, Karnataka'), findsOneWidget); + await tester.enterText(find.widgetWithText(TextField, 'Meera Anand'), 'Meera Anand'); + await tester.enterText(find.widgetWithText(TextField, '90031 44518'), '9003144518'); + await settle(tester, 200); await tester.tap(find.text('Book pickup')); await settle(tester); - final booking = state.trackedBooking!; - expect(booking.destinations, hasLength(3)); - expect(booking.totalPackages, 6); - expect(booking.packageSummary, '6 packages · 3 destinations'); - // Before collection it is one pickup, however many places it serves. - expect( - state - .entriesOf(BookingStatus.active) - .where((e) => e.booking.reference == booking.reference) - .length, - 1, - ); + + final details = state.trackedBooking!.destinations.first.details; + expect(details.building, '12B, Nehru Apartments'); + expect(details.street, 'Anna Nagar West'); + expect(details.recipientName, 'Meera Anand'); + // The country code is added once, here, rather than being typed. + expect(details.recipientPhone, '+91 9003144518'); await drainToasts(tester); }); - testWidgets('the package cap holds', (tester) async { + testWidgets('the package stepper is on the screen and clamps', (tester) async { final state = AppState(); await signIn(tester, state: state); - await openDestination(tester); - await pickPlace(tester, 'Tamil Nadu', 'Chennai'); + await openSend(tester); + await pickCity(tester, 'Coimbatore'); - // Push well past the cap; `setPackageCount` must clamp. + expect(find.text('1 package'), findsOneWidget); + await tester.tap(find.byIcon(LucideIcons.plus)); + await tester.pump(); + await tester.tap(find.byIcon(LucideIcons.plus)); + await tester.pump(); + expect(find.text('3 packages'), findsOneWidget); + expect(state.draftTotalPackages, 3); + + // Push past the cap on the state; the clamp is the model's, not the UI's. state.setPackageCount(0, 999); await tester.pump(); - expect(state.draftTotalPackages, state.limits.maxPackages); - // A full pickup cannot take another place either. - expect(state.canAddDestination, isFalse); - expect( - find.text('Up to ${state.limits.maxPackages} packages in one pickup.'), - findsOneWidget, - ); + + await tester.tap(find.text('Book pickup')); + await settle(tester); + expect(state.trackedBooking!.totalPackages, state.limits.maxPackages); await drainToasts(tester); }); - testWidgets('the destination cap holds', (tester) async { + testWidgets('the window sheet changes the window', (tester) async { final state = AppState(); await signIn(tester, state: state); - await openDestination(tester); + await openSend(tester); + await pickCity(tester, 'Chennai'); + final first = state.draftSlotId; - const places = [ - ('Tamil Nadu', 'Chennai'), - ('Kerala', 'Ernakulam'), - ('Karnataka', 'Mysuru'), - ('Tamil Nadu', 'Erode'), - ('Kerala', 'Thrissur'), - ]; + await tester.tap(find.text('Change').last); + await settle(tester); + expect(find.text('Pickup window'), findsOneWidget); - for (var i = 0; i < places.length; i++) { - if (i > 0) { - await tester.tap(find.text('Send to another place')); - await settle(tester); - } - await pickPlace(tester, places[i].$1, places[i].$2); - } + // A different window, then commit it. + await tester.tap(find.text('4:00 – 6:00 PM').first); + await tester.pump(); + await tester.tap(find.textContaining('Use 4:00 – 6:00 PM')); + await settle(tester); - expect(state.draftDestinations, hasLength(state.limits.maxDestinations)); - expect(state.canAddDestination, isFalse); - expect(find.text('Send to another place'), findsNothing); - expect( - find.text( - 'Up to ${state.limits.maxDestinations} places in one pickup. ' - 'Book another pickup for more.', - ), - findsOneWidget, - ); + expect(state.draftSlotId, isNot(first)); + expect(find.text('Pickup window'), findsNothing); await drainToasts(tester); }); - testWidgets('the picker offers serviceable districts only', (tester) async { + testWidgets('the city strip offers serviceable cities only', (tester) async { await signIn(tester); - await openDestination(tester); + await openSend(tester); - // Nothing until a state is picked. - expect(find.text('Chennai'), findsNothing); - - await tapState(tester, 'Tamil Nadu'); + // One flat strip: the state step is gone, so the cities of every open + // state stand side by side. expect(find.text('Chennai'), findsOneWidget); - expect(find.text('Coimbatore'), findsOneWidget); - expect(find.text('Ernakulam'), findsNothing); + expect(find.text('Coimbatore'), findsWidgets); + await pickCity(tester, 'Ernakulam'); + expect(find.text('Ernakulam'), findsWidgets); - // Unsupported places are never disabled tiles — they are named once, - // quietly, and only because they are genuinely coming. + // Places that are not open are never offered — not as disabled cards, and + // not as a state whose every district is closed. expect(find.text('Madurai'), findsNothing); - expect(find.text('Coming soon: Madurai'), findsOneWidget); - - await tapState(tester, 'Kerala'); - expect(find.text('Ernakulam'), findsOneWidget); - expect(find.text('Palakkad'), findsOneWidget); - expect(find.text('Chennai'), findsNothing); - expect(find.text('Coming soon: Kozhikode'), findsOneWidget); - await drainToasts(tester); - }); - - testWidgets('a state with nothing open is not offered at all', (tester) async { - await signIn(tester); - await openDestination(tester); - - // Puducherry is in the backend response but has no open district, so the - // customer never has to read past it. expect(find.text('Puducherry'), findsNothing); - expect(find.text('Tamil Nadu'), findsOneWidget); await drainToasts(tester); }); @@ -663,9 +585,9 @@ void main() { await signIn(tester); api.flags.emptyServiceAreas = true; - await openDestination(tester); + await openSend(tester); - expect(find.text('No service areas available'), findsOneWidget); + expect(find.text('No cities open yet'), findsOneWidget); await drainToasts(tester); }); @@ -673,14 +595,15 @@ void main() { await signIn(tester); api.flags.networkError = true; - await openDestination(tester); + await openSend(tester); expect(find.text("Couldn't load this"), findsOneWidget); api.flags.networkError = false; await tester.tap(find.text('Retry')); await settle(tester); - expect(find.text('Tamil Nadu'), findsOneWidget); + // The strip comes back with the cities, not with a state to pick first. + expect(find.text('Coimbatore'), findsWidgets); await drainToasts(tester); }); @@ -718,17 +641,8 @@ void main() { await signIn(tester); // login + OTP expect(find.text('Start booking'), findsOneWidget); // home - await openDestination(tester); // pickup point + destination - await pickPlace(tester, 'Tamil Nadu', 'Chennai'); - expect(find.text('Chennai, Tamil Nadu'), findsOneWidget); - - await tester.tap(find.text('Next')); - await settle(tester); // pickup time - await tester.tap(find.text('2:00 – 4:00 PM').first); - await tester.pump(); - await tester.tap(find.text('Review booking')); - await settle(tester); // review - expect(find.text('Review pickup details'), findsOneWidget); + await openSend(tester); // the whole booking, one screen + await pickCity(tester, 'Chennai'); await tester.tap(find.text('Book pickup')); await settle(tester); // confirmation diff --git a/test/design_snapshot_test.dart b/test/design_snapshot_test.dart index 34c3932..7408fec 100644 --- a/test/design_snapshot_test.dart +++ b/test/design_snapshot_test.dart @@ -128,27 +128,30 @@ void main() { await tester.tap(find.byIcon(LucideIcons.arrowLeft).first); await _settle(tester); - // Booking flow. + // Booking: one screen, plus the two escape hatches. await tester.tap(find.text('Start booking')); await _settle(tester); - await _shot(tester, '07-pickup-point'); - await tester.tap(find.text('Next')); - await _settle(tester); - await tester.tap(find.text('Tamil Nadu')); - await _settle(tester); - await tester.tap(find.text('Chennai')); - await _settle(tester); - await _shot(tester, '08-destination'); + await _shot(tester, '07-send-empty'); - await tester.tap(find.text('Next')); + await tester.tap(find.text('Chennai').first); await _settle(tester); - await tester.tap(find.text('2:00 – 4:00 PM').first); - await _settle(tester); - await _shot(tester, '09-slot'); + await _shot(tester, '08-send-ready'); - await tester.tap(find.text('Review booking')); + // The window row is below the fold on a phone, and a ListView does not + // build what it has not reached. + // The window already carries the first available slot, so scroll to the + // package row beneath it — a string that is there either way. + await tester.dragUntilVisible( + find.text('1 package'), + find.byType(Scrollable).first, + const Offset(0, -220), + ); + await _settle(tester); + await tester.tap(find.text('Change').last); + await _settle(tester); + await _shot(tester, '09-window-sheet'); + await tester.tap(find.textContaining('Use ').first); await _settle(tester); - await _shot(tester, '10-review'); await tester.tap(find.text('Book pickup')); await _settle(tester); diff --git a/test/login_otp_guard_test.dart b/test/login_otp_guard_test.dart index 6ec7c9a..f559e21 100644 --- a/test/login_otp_guard_test.dart +++ b/test/login_otp_guard_test.dart @@ -66,9 +66,12 @@ void main() { reason: 'no code was delivered, so there is nothing to verify', ); expect( - // The entry screen's own headline, which now sits on the brand field - // rather than as a heading inside the sheet. - find.text('Book a pickup from your door.'), + // The entry screen's heading. It used to be the promise on the crimson + // ("Book a pickup from your door."); the screen was cut back to an + // instruction, a field and a button, so the heading is now the + // instruction. What this asserts is unchanged: the customer is still on + // the sign-in screen and can try again. + find.text('Enter your mobile number'), findsOneWidget, reason: 'the customer stays where they can try again', ); diff --git a/test/snapshots/01-sign-in.png b/test/snapshots/01-sign-in.png index c91d261..4c1cf3e 100644 Binary files a/test/snapshots/01-sign-in.png and b/test/snapshots/01-sign-in.png differ diff --git a/test/snapshots/03-home.png b/test/snapshots/03-home.png index 757f4f6..83c0b1b 100644 Binary files a/test/snapshots/03-home.png and b/test/snapshots/03-home.png differ diff --git a/test/snapshots/04-orders.png b/test/snapshots/04-orders.png index 5632c52..82e9405 100644 Binary files a/test/snapshots/04-orders.png and b/test/snapshots/04-orders.png differ diff --git a/test/snapshots/05-account.png b/test/snapshots/05-account.png index 678355a..f4fdd56 100644 Binary files a/test/snapshots/05-account.png and b/test/snapshots/05-account.png differ diff --git a/test/snapshots/07-pickup-point.png b/test/snapshots/07-pickup-point.png deleted file mode 100644 index 5fa0658..0000000 Binary files a/test/snapshots/07-pickup-point.png and /dev/null differ diff --git a/test/snapshots/07-send-empty.png b/test/snapshots/07-send-empty.png new file mode 100644 index 0000000..1750c46 Binary files /dev/null and b/test/snapshots/07-send-empty.png differ diff --git a/test/snapshots/08-destination.png b/test/snapshots/08-destination.png deleted file mode 100644 index 49fc160..0000000 Binary files a/test/snapshots/08-destination.png and /dev/null differ diff --git a/test/snapshots/08-send-ready.png b/test/snapshots/08-send-ready.png new file mode 100644 index 0000000..3282a01 Binary files /dev/null and b/test/snapshots/08-send-ready.png differ diff --git a/test/snapshots/09-slot.png b/test/snapshots/09-slot.png deleted file mode 100644 index e43be23..0000000 Binary files a/test/snapshots/09-slot.png and /dev/null differ diff --git a/test/snapshots/09-window-sheet.png b/test/snapshots/09-window-sheet.png new file mode 100644 index 0000000..6e659d1 Binary files /dev/null and b/test/snapshots/09-window-sheet.png differ diff --git a/test/snapshots/10-review.png b/test/snapshots/10-review.png deleted file mode 100644 index a4dc53b..0000000 Binary files a/test/snapshots/10-review.png and /dev/null differ diff --git a/test/snapshots/11-booked.png b/test/snapshots/11-booked.png index 40e046b..47396b5 100644 Binary files a/test/snapshots/11-booked.png and b/test/snapshots/11-booked.png differ diff --git a/test/snapshots/12-receipt.png b/test/snapshots/12-receipt.png index 03c319a..f39ca8c 100644 Binary files a/test/snapshots/12-receipt.png and b/test/snapshots/12-receipt.png differ