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:
@@ -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.
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user