Booking and sign-in flow, and the offline build back under guard

Picks up where 0d66627 left off. Three things.

SIGN-IN, CUT TO THE QUESTION IT ASKS
It 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. It is a
heading, a field and a button now, which is where Uber, Bolt and Porter put
them. The toggle became one quiet line under the field — choosing a method was
the first decision on the screen, before the customer had seen what was being
asked, and almost everyone uses the phone. The privacy card went: it explained
that a code would be sent, which the next screen demonstrates a second later.

`DmTextField.label` is nullable for this — "Enter your mobile number" above a
field captioned "Phone number" is one sentence printed twice.

BOOKING, DOWN TO ONE SCREENFUL
Landmark, recipient name and recipient phone are all optional and were all
drawn at the weight of the two fields that are not, putting six rows of "you
may skip this" between the address and the button. They fold behind one row
that counts what is filled in rather than just saying "optional".

Four crimson section heads became one. An accent used five times on a screen is
not an accent; crimson now marks the destination, which is the only choice that
changes the price.

Together those put the window, the package count and the CTA above the fold.

THE OFFLINE BUILD, BACK, UNDER TWO RULES
Deleted on 15 Sep after it cost two rounds of hunting for bookings in the admin
console that had never left the phone. That was not caused by the fake
existing — it was caused by a fake that did not announce itself and that
nothing stopped from shipping. Both are closed:

  * `useDevData` is false in a release whatever the defines say;
  * `describe` leads with DEV DATA (offline) and shows "no network" rather than
    a host the build never contacts.

It is opt-in — `flutter run` still talks to the real API — which is the
property whose absence caused the original mess. `devAutoLogin` is deliberately
false under FLUTTER_TEST so the widget tests keep driving the real entrance.

    flutter run --dart-define=DM_MOCK=true

86 tests green, analyze clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
This commit is contained in:
2026-09-21 13:10:28 +05:30
parent 0d66627c3c
commit 8207e27a97
37 changed files with 1382 additions and 1566 deletions

8
.gitignore vendored
View File

@@ -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/

View File

@@ -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 <simulator> --dart-define=DM_MOCK=true
/// -d <device> \
/// --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=<server-issued 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<void> 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');
});
}

View File

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

View File

@@ -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**.
///

View File

@@ -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();

View File

@@ -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,

View File

@@ -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<void> _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<DestinationGroup> 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<List<CityOption>> 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<String> upcomingDistricts(String stateCode) => (districtCache[stateCode] ?? const [])

View File

@@ -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<LoginScreen> {
@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<LoginScreen> {
],
),
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.
],
);
}

View File

@@ -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<T> bookingRoute<T>(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<void>(name, builder));
}
}

View File

@@ -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<ConfirmedScreen>
],
),
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.',
),
],
],
),

View File

@@ -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<DestinationScreen> createState() => _DestinationScreenState();
}
class _DestinationScreenState extends State<DestinationScreen> {
/// 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<void>(
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<ServiceArea>(
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<District>(
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,
),
),
),
],
),
),
);
}
}

View File

@@ -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<PickupLocationScreen> {
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<PickupLocationScreen> {
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<void>(
BookingRoutes.destination,
(_) => const DestinationScreen(),
),
);
Navigator.of(context).maybePop();
},
),
],

View File

@@ -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<ReviewScreen> createState() => _ReviewScreenState();
}
class _ReviewScreenState extends State<ReviewScreen> {
bool _busy = false;
Future<void> _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<void>(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<DestinationGroup> 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),
),
),
],
),
);
}
}

View File

@@ -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<SendScreen> createState() => _SendScreenState();
}
class _SendScreenState extends State<SendScreen> {
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<void> _book(AppState app) async {
_commitDetails(app);
setState(() => _busy = true);
final navigator = Navigator.of(context);
try {
await app.confirmBooking();
unawaited(HapticFeedback.mediumImpact());
navigator.pushAndRemoveUntil(
bookingRoute<void>(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<void>(
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<void> _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<CityOption> 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<CityOption>(
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,
),
),
],
),
),
),
);
}
}

View File

@@ -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<SlotScreen> createState() => _SlotScreenState();
}
class _SlotScreenState extends State<SlotScreen> {
@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<PickupSlot>(
// 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 = <String>[];
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<void>(
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"),
),
);
}
}

View File

@@ -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<void>(
BookingRoutes.pickup,
(_) => const PickupLocationScreen(),
),
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
);
}

View File

@@ -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<PickupSlot?> showWindowSheet(BuildContext context) {
return showDmSheet<PickupSlot>(
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<PickupSlot>(
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 = <String>[];
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<PickupSlot> 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"),
),
);
}
}

View File

@@ -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<void>(
BookingRoutes.pickup,
(_) => const PickupLocationScreen(),
),
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
(route) => route.isFirst,
);
},

View File

@@ -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,
],
),

View File

@@ -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<DmTextField> {
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,

View File

@@ -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.
});

View File

@@ -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<void> serverAdvance(
await settle(tester, 1400);
}
/// Home ▸ BOOK ▸ confirm the pin ▸ destination step.
Future<void> 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<void> 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<void> tapState(WidgetTester tester, String name) async {
/// Opens the pickup-point editor from the send screen's "Change".
Future<void> 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<void> 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<void> 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<DmMapPanel>(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<DmButton>(find.widgetWithText(DmButton, 'Next')).onPressed,
tester
.widget<DmButton>(
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

View File

@@ -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);

View File

@@ -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',
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 176 KiB