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:
@@ -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)' : ''}';
|
||||
|
||||
@@ -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**.
|
||||
///
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
@@ -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.
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
506
lib/ui/screens/booking/send_screen.dart
Normal file
506
lib/ui/screens/booking/send_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
192
lib/ui/screens/sheets/window_sheet.dart
Normal file
192
lib/ui/screens/sheets/window_sheet.dart
Normal 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"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user