PIN sign-in, because the code could never arrive
── What was actually broken ── The SMS gateway was switched off, and `POST /auth/otp/request` does not fail when that happens: it still answers `sent: true`, still issues a valid 4-digit code, and writes it to the **server log**. So the phone path walked customers to a code screen for a code that could not arrive, and every digit they eventually typed was wrong. The failure read to them as "I entered it wrong". Phone sign-in is now a PIN, which needs no gateway. ── Email still sends codes, so email is untouched ── Email OTP goes over SMTP and works. Deleting a working way in to tidy up a broken one is a net loss for anyone with an email on their account, so "Use email instead" and the code screen stay exactly as they were. `login_otp_guard_test` moves to that path — the `sent: false` guard still matters there, and that is now the only place it can fire. ── One screen, three entrances ── `POST /auth/login` says which of them a number is before anything is asked, so the app never guesses. Guessing is not cosmetic: offer "create a PIN" to a returning customer and the server answers `pin_already_set` on a screen that cannot succeed; offer "enter your PIN" to somebody who has never set one and every attempt is wrong. The separate sign-up screen is deleted rather than hidden. It asked for a name and then sent an SMS code — a second entrance asking the same questions and posting a letter that never lands. A new number now gives its name and PIN on the same screen. ── A second sign-in path found a latent bug ── `AppState.signIn` only started `refreshOrders`, and the OTP screen called `detectPickupLocation` itself afterwards to make up the difference. That held exactly as long as there was one sign-in screen. PIN sign-in did not know about the extra call, so Home opened with no pickup and no serviceable cities. The work belongs to signing in, not to whichever screen happened to be last, so it moved into `signIn` and the OTP screen's copy is gone. ── What the screen deliberately does not do ── It does not greet by name. `POST /auth/login` returns the account holder's name, which tells anybody who types a number who owns it; the field is read but never displayed, so it disappears quietly when the backend drops it. It does not say whether the number or the PIN was wrong — the server answers identically for both on purpose, and narrowing it here would turn sign-in into a way of testing whether a number has an account. "Forgot your PIN?" renders only when a support contact is configured. There is no reset endpoint, so it can only point at a human — and telling somebody locked out that help exists without saying where is worse than silence. ── The handover note does not reach the Miler ── The app said "we pass this to your Miler as a note". It does not: `remarks` reaches the admin console and stops, because the rider app reads a `notes` field per stop that the backend never sends. A customer could hand their parcel to a neighbour believing the Miler had been told. Both screens now say it is recorded on the booking, and that the Miler still calls the account's number. ── Also ── DmTextField gains `obscure`, and PinScreen carries a back button — without one the only correction for a mistyped digit was killing the app.
This commit is contained in:
@@ -7,11 +7,27 @@ import '../../widgets/feedback.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import 'auth_scaffold.dart';
|
||||
import 'otp_screen.dart';
|
||||
import 'signup_screen.dart';
|
||||
import 'pin_screen.dart';
|
||||
|
||||
enum LoginMode { phone, email }
|
||||
|
||||
/// Sign in — phone or email, then a 4-digit code.
|
||||
/// Sign in — a phone number and a PIN, or an email and a code.
|
||||
///
|
||||
/// ── Why the two halves work differently ──
|
||||
///
|
||||
/// They used to be the same: both sent a 4-digit code. The SMS gateway was
|
||||
/// then switched off, and `POST /auth/otp/request` does not fail when that
|
||||
/// happens — it still answers `sent: true` and still issues a valid code,
|
||||
/// writing it to the **server log** instead of sending it. So the phone path
|
||||
/// walked customers to four boxes for a code that could not arrive, and every
|
||||
/// value they typed was wrong.
|
||||
///
|
||||
/// Phone now goes to [PinScreen]: a PIN the customer chooses needs no gateway.
|
||||
///
|
||||
/// **Email still sends a code, and still works** — it goes over SMTP, which is
|
||||
/// unaffected — so that path is untouched. It is kept rather than removed
|
||||
/// because deleting a working way in to tidy up a broken one is a net loss for
|
||||
/// anyone who has an email on their account.
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@@ -46,6 +62,33 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
final raw = _identifier.text.trim();
|
||||
setState(() => _sending = true);
|
||||
final target = _isPhone ? '+91 $raw' : raw;
|
||||
final navigator = Navigator.of(context);
|
||||
|
||||
// ── A phone number does not get a code any more ──
|
||||
//
|
||||
// `POST /auth/login` says which of the three PIN screens this number
|
||||
// leads to, and [PinScreen] renders that one. Asking first is what stops
|
||||
// the app offering "enter your PIN" to somebody who has never set one.
|
||||
if (_isPhone) {
|
||||
try {
|
||||
final check = await AppScope.read(context).checkPhone(target);
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
await navigator.push(
|
||||
MaterialPageRoute<void>(builder: (_) => PinScreen(check: check)),
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
DmToast.show(context, e.message);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
DmToast.show(context, 'Something went wrong. Please try again.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final challenge = await AppScope.read(context).sendOtp(target);
|
||||
if (!mounted) return;
|
||||
@@ -63,8 +106,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
if (!challenge.sent) {
|
||||
DmToast.show(
|
||||
context,
|
||||
'We could not send a code to that ${_isPhone ? 'number' : 'address'}. '
|
||||
'Try again in a moment.',
|
||||
'We could not send a code to that address. Try again in a moment.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -114,20 +156,14 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
// 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: [
|
||||
AuthSwitchLink(
|
||||
question: 'New to Doormile?',
|
||||
action: 'Create account',
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const SignUpScreen()),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const AuthLegal(),
|
||||
],
|
||||
),
|
||||
// ── No "Create account" link ──
|
||||
//
|
||||
// It pushed a separate sign-up screen that asked for a name and then
|
||||
// sent an SMS code. A new number is now recognised by
|
||||
// `POST /auth/login` and [PinScreen] asks for the name and the PIN in
|
||||
// one step, so a second entrance would ask the same questions twice and
|
||||
// send a code that cannot arrive.
|
||||
footer: const AuthLegal(),
|
||||
children: [
|
||||
// No label above it. The heading already said what to type, and a
|
||||
// field captioned "Phone number" under a heading reading "Enter your
|
||||
@@ -163,7 +199,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
DmButton(
|
||||
label: 'Continue',
|
||||
busy: _sending,
|
||||
busyLabel: 'Sending code…',
|
||||
// The phone path sends nothing — it asks which screen comes next.
|
||||
busyLabel: _isPhone ? 'Checking…' : 'Sending code…',
|
||||
onPressed: _valid ? _continue : null,
|
||||
),
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ class _OtpScreenState extends State<OtpScreen> {
|
||||
try {
|
||||
await app.verifyOtp(widget.identifier, _digits, name: widget.name);
|
||||
if (!mounted) return;
|
||||
app.detectPickupLocation();
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
await navigator.pushAndRemoveUntil(
|
||||
MaterialPageRoute<void>(builder: (_) => const ShellScreen()),
|
||||
|
||||
290
lib/ui/screens/auth/pin_screen.dart
Normal file
290
lib/ui/screens/auth/pin_screen.dart
Normal file
@@ -0,0 +1,290 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../data/api_exception.dart';
|
||||
import '../../../data/app_config.dart';
|
||||
import '../../../data/models.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import '../shell_screen.dart';
|
||||
import 'auth_scaffold.dart';
|
||||
|
||||
/// The second step of phone sign-in: a 4-digit PIN.
|
||||
///
|
||||
/// ── Why this replaced the code screen ──
|
||||
///
|
||||
/// The SMS gateway was switched off. `POST /auth/otp/request` still answers
|
||||
/// `sent: true` and still issues a valid code — it writes it to the **server
|
||||
/// log** instead of sending it. So the code screen became four boxes nobody
|
||||
/// could ever fill: the customer waits for an SMS that cannot arrive, and
|
||||
/// everything they eventually type is wrong.
|
||||
///
|
||||
/// A PIN the customer chooses needs no gateway. Email OTP still works and is
|
||||
/// still offered on the screen before this one — this replaces the phone path
|
||||
/// only.
|
||||
///
|
||||
/// ── One screen, three entrances ──
|
||||
///
|
||||
/// `POST /auth/login` has already said which of these a number is, so this
|
||||
/// screen never guesses:
|
||||
///
|
||||
/// * [PhoneStep.enterPin] — a returning customer types theirs
|
||||
/// * [PhoneStep.createPin] — an account with no PIN yet chooses one
|
||||
/// * [PhoneStep.createAccount] — a new number gives a name and a PIN
|
||||
///
|
||||
/// Guessing is not a cosmetic risk. Ask a returning customer to invent a PIN
|
||||
/// and the server answers `pin_already_set`, leaving them on a screen that
|
||||
/// cannot succeed; ask a new customer for the PIN they have never set and
|
||||
/// every attempt is wrong.
|
||||
///
|
||||
/// ── What this screen does not say ──
|
||||
///
|
||||
/// `POST /auth/login` returns the account holder's **name**, and the backend
|
||||
/// has been asked to stop sending it, because it tells anybody who types a
|
||||
/// number who owns it. This screen does not greet the customer by it. A
|
||||
/// friendlier screen is not worth a free lookup of who owns a phone number.
|
||||
class PinScreen extends StatefulWidget {
|
||||
const PinScreen({super.key, required this.check});
|
||||
|
||||
/// The answer from `POST /auth/login` — which entrance this is.
|
||||
final PhoneCheck check;
|
||||
|
||||
@override
|
||||
State<PinScreen> createState() => _PinScreenState();
|
||||
}
|
||||
|
||||
class _PinScreenState extends State<PinScreen> {
|
||||
final _pin = TextEditingController();
|
||||
final _confirm = TextEditingController();
|
||||
final _name = TextEditingController();
|
||||
bool _busy = false;
|
||||
|
||||
/// Set when the server refuses, and cleared on the next keystroke, so the
|
||||
/// reason sits under the field the customer is fixing rather than in a toast
|
||||
/// that has gone by the time they look.
|
||||
String? _error;
|
||||
|
||||
PhoneStep get _step => widget.check.step;
|
||||
bool get _creating => _step != PhoneStep.enterPin;
|
||||
bool get _needsName => _step == PhoneStep.createAccount;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final c in [_pin, _confirm, _name]) {
|
||||
c.addListener(() => setState(() => _error = null));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pin.dispose();
|
||||
_confirm.dispose();
|
||||
_name.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _valid {
|
||||
if (_pin.text.length != 4) return false;
|
||||
if (!_creating) return true;
|
||||
if (_confirm.text != _pin.text) return false;
|
||||
return !_needsName || _name.text.trim().length >= 2;
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final app = AppScope.read(context);
|
||||
final navigator = Navigator.of(context);
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
if (_creating) {
|
||||
await app.setPin(
|
||||
phone: widget.check.phone,
|
||||
pin: _pin.text,
|
||||
name: _needsName ? _name.text.trim() : null,
|
||||
);
|
||||
} else {
|
||||
await app.verifyPin(phone: widget.check.phone, pin: _pin.text);
|
||||
}
|
||||
if (!mounted) return;
|
||||
// Everything below the sign-in screens goes, so back does not land on a
|
||||
// PIN field with a live session behind it.
|
||||
await navigator.pushAndRemoveUntil(
|
||||
MaterialPageRoute<void>(builder: (_) => const ShellScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = _reasonFor(e);
|
||||
});
|
||||
// ── The two that are not a typo ──
|
||||
//
|
||||
// `pin_not_set` and `pin_already_set` mean this screen is the wrong one
|
||||
// for this number — the account changed between the check and the
|
||||
// submit, or the check was wrong. Re-asking is the only recovery, and it
|
||||
// is one tap, so the screen says so and steps back rather than leaving
|
||||
// somebody retyping a PIN that can never be right.
|
||||
if (e.code == ApiException.pinNotSet ||
|
||||
e.code == ApiException.pinAlreadySet) {
|
||||
navigator.pop();
|
||||
}
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = 'Something went wrong. Please try again.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _reasonFor(ApiException e) => switch (e.code) {
|
||||
// Deliberately does not say which. The server answers the same for a wrong
|
||||
// PIN and an unknown number, and narrowing it here would turn sign-in into
|
||||
// a way of testing whether a number has an account.
|
||||
ApiException.invalidPin => 'That number or PIN is incorrect',
|
||||
// Not in the contract yet — the backend is adding a per-account lockout.
|
||||
// Its own message is the useful one, because only the server knows how
|
||||
// long is left.
|
||||
ApiException.pinLocked => e.message,
|
||||
ApiException.pinNotSet => 'This number has no PIN yet. Create one.',
|
||||
ApiException.pinAlreadySet => 'This number already has a PIN. Enter it.',
|
||||
_ => e.message,
|
||||
};
|
||||
|
||||
String get _title => switch (_step) {
|
||||
PhoneStep.enterPin => 'Enter your PIN',
|
||||
PhoneStep.createPin => 'Create a PIN',
|
||||
PhoneStep.createAccount => 'Create your account',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AuthScaffold(
|
||||
compactBanner: true,
|
||||
badge: false,
|
||||
title: _title,
|
||||
// ── A way back to the number ──
|
||||
//
|
||||
// Without this the only correction for a mistyped digit is killing the
|
||||
// app: the phone screen is behind this one and nothing on this one
|
||||
// returns to it. The customer can see the number they are signing in to
|
||||
// in the line below the title, which is exactly when they notice it is
|
||||
// wrong.
|
||||
onBack: _busy ? null : () => Navigator.of(context).maybePop(),
|
||||
footer: const AuthLegal(),
|
||||
children: [
|
||||
Text(
|
||||
_creating
|
||||
? 'A 4-digit PIN signs you in from now on. '
|
||||
'It is how you get back to ${widget.check.phone}.'
|
||||
: 'For ${widget.check.phone}.',
|
||||
style: DmText.small.copyWith(color: DmColors.ink3, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
if (_needsName) ...[
|
||||
DmTextField(
|
||||
controller: _name,
|
||||
hint: 'Your full name',
|
||||
keyboardType: TextInputType.name,
|
||||
maxLength: 60,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
DmTextField(
|
||||
controller: _pin,
|
||||
hint: _creating ? 'Choose a 4-digit PIN' : '4-digit PIN',
|
||||
keyboardType: TextInputType.number,
|
||||
digitsOnly: true,
|
||||
maxLength: 4,
|
||||
obscure: true,
|
||||
mono: true,
|
||||
autofocus: !_needsName,
|
||||
textInputAction: _creating
|
||||
? TextInputAction.next
|
||||
: TextInputAction.done,
|
||||
onSubmitted: (_) => _valid && !_creating ? _submit() : null,
|
||||
),
|
||||
|
||||
if (_creating) ...[
|
||||
const SizedBox(height: 12),
|
||||
DmTextField(
|
||||
controller: _confirm,
|
||||
hint: 'Enter it again',
|
||||
keyboardType: TextInputType.number,
|
||||
digitsOnly: true,
|
||||
maxLength: 4,
|
||||
obscure: true,
|
||||
mono: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _valid ? _submit() : null,
|
||||
),
|
||||
// Said before they choose, not after they forget. There is no reset
|
||||
// endpoint yet — see the support line below — so a forgotten PIN is
|
||||
// a phone call, and that is worth knowing at the moment of choosing
|
||||
// one rather than six weeks later.
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Pick something you will remember. '
|
||||
'Changing it later means contacting support.',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
height: 1.45,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_error!,
|
||||
style: DmText.small.copyWith(color: DmColors.brand, height: 1.45),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 18),
|
||||
DmButton(
|
||||
label: _creating ? 'Create PIN and continue' : 'Sign in',
|
||||
busy: _busy,
|
||||
busyLabel: _creating ? 'Creating…' : 'Signing in…',
|
||||
onPressed: _valid ? _submit : null,
|
||||
),
|
||||
|
||||
// ── Forgotten PINs have no self-service path ──
|
||||
//
|
||||
// The backend has no reset or change endpoint for customers, so this
|
||||
// cannot be a link that does something — it can only point at a human.
|
||||
// It renders **only when a support contact is configured**
|
||||
// (`AppConfig.supportPhone` / `supportEmail`, both empty by default),
|
||||
// because "contact support" with no way to contact them is worse than
|
||||
// silence: it tells somebody locked out that help exists and then does
|
||||
// not say where.
|
||||
if (!_creating && AppConfig.hasSupportContact) ...[
|
||||
const SizedBox(height: 14),
|
||||
Center(
|
||||
child: DmTextAction(
|
||||
label: 'Forgot your PIN?',
|
||||
onPressed: () => DmToast.show(
|
||||
context,
|
||||
AppConfig.supportPhone.isNotEmpty
|
||||
? 'Call ${AppConfig.supportPhone} and we will reset it'
|
||||
: 'Email ${AppConfig.supportEmail} and we will reset it',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../data/doormile_api.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import 'auth_scaffold.dart';
|
||||
import 'otp_screen.dart';
|
||||
|
||||
/// Create an account — name and phone, with email optional.
|
||||
///
|
||||
/// The same OTP screen verifies both sign in and sign up; the name is carried
|
||||
/// through so the new account is created with it.
|
||||
class SignUpScreen extends StatefulWidget {
|
||||
const SignUpScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SignUpScreen> createState() => _SignUpScreenState();
|
||||
}
|
||||
|
||||
class _SignUpScreenState extends State<SignUpScreen> {
|
||||
final _name = TextEditingController();
|
||||
final _phone = TextEditingController();
|
||||
final _email = TextEditingController();
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final c in [_name, _phone, _email]) {
|
||||
c.addListener(() => setState(() {}));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in [_name, _phone, _email]) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _valid =>
|
||||
_name.text.trim().length >= 2 && _phone.text.trim().length >= 10;
|
||||
|
||||
Future<void> _continue() async {
|
||||
setState(() => _sending = true);
|
||||
final phone = '+91 ${_phone.text.trim()}';
|
||||
try {
|
||||
final challenge = await AppScope.read(context).signUp(
|
||||
name: _name.text.trim(),
|
||||
phone: phone,
|
||||
email: _email.text.trim().isEmpty ? null : _email.text.trim(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => OtpScreen(
|
||||
identifier: phone,
|
||||
name: _name.text.trim(),
|
||||
challenge: challenge,
|
||||
),
|
||||
),
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
DmToast.show(context, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AuthScaffold(
|
||||
onBack: () => Navigator.of(context).maybePop(),
|
||||
// Same entrance as sign in: the headline sits on the brand field at
|
||||
// display size and the sheet starts straight into the form. Sign in and
|
||||
// sign up are one doorway with two doors, so they cannot be laid out
|
||||
// differently — a customer who bounces between them should see the
|
||||
// screen change its words, not its shape.
|
||||
// One line, no supporting sentence. "A few details and you can book
|
||||
// your first pickup" described the three fields directly underneath it,
|
||||
// on a screen whose whole content is those three fields.
|
||||
heroTitle: 'Create your account',
|
||||
badge: false,
|
||||
// Pinned for the same reason as sign in: a three-field form plus the
|
||||
// keyboard leaves no room, and the CTA must not be the thing that loses.
|
||||
footer: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DmButton(
|
||||
label: 'Continue',
|
||||
busy: _sending,
|
||||
busyLabel: 'Sending code…',
|
||||
onPressed: _valid ? _continue : null,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
AuthSwitchLink(
|
||||
question: 'Already have an account?',
|
||||
action: 'Sign in',
|
||||
onTap: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const AuthLegal(),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
// Floating labels, and no hints behind them: a caption above an empty
|
||||
// field and an example inside it are two ways of asking the same
|
||||
// question, and three of those stacked is six rows of chrome around
|
||||
// three answers. The label sits where the answer will go and floats
|
||||
// out once there is something to caption.
|
||||
DmTextField(
|
||||
label: 'Full name',
|
||||
floating: true,
|
||||
controller: _name,
|
||||
keyboardType: TextInputType.name,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
DmTextField(
|
||||
label: 'Phone number',
|
||||
floating: true,
|
||||
controller: _phone,
|
||||
prefix: '+91',
|
||||
keyboardType: TextInputType.phone,
|
||||
digitsOnly: true,
|
||||
maxLength: 10,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
DmTextField(
|
||||
label: 'Email',
|
||||
floating: true,
|
||||
optional: true,
|
||||
controller: _email,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _valid ? _continue() : null,
|
||||
),
|
||||
// ── The reassurance banner is gone ──
|
||||
//
|
||||
// "We verify every number · A 4-digit code confirms it's you and keeps
|
||||
// your parcels secure." It was the last thing in a scrolling list
|
||||
// under a pinned footer, so on a 720p phone the footer cut it in half
|
||||
// and the customer read "…keeps your parcels secu". A reassurance that
|
||||
// is clipped reassures nobody.
|
||||
//
|
||||
// It also answered a question nobody had yet: the customer finds out
|
||||
// about the code on the next screen, which is called "Verify your
|
||||
// number" and says so.
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -472,13 +472,22 @@ class _ConfirmSheet extends StatelessWidget {
|
||||
// ── Said here, not discovered later ──
|
||||
//
|
||||
// The rider's call button dials the account, and
|
||||
// nothing this app sends can change that. Better
|
||||
// to say so beside the field than to let somebody
|
||||
// hand their parcel to a neighbour believing the
|
||||
// Miler has the neighbour's number.
|
||||
// nothing this app sends can change that.
|
||||
//
|
||||
// This used to say "we pass this to your Miler as
|
||||
// a note", and that was wrong. `remarks` reaches
|
||||
// the admin console and stops there: the rider app
|
||||
// reads a `notes` field per stop and the backend
|
||||
// never sends one. A customer reading the old
|
||||
// sentence could hand their parcel to a neighbour
|
||||
// believing the Miler had been told, when nobody
|
||||
// in the field had. Corrected until the backend
|
||||
// carries a real handover contact — see
|
||||
// docs/BACKEND_CHANGES.md §6.
|
||||
Text(
|
||||
'We pass this to your Miler as a note. Their '
|
||||
'call button still dials your own number.',
|
||||
'We record this on your booking. Your Miler '
|
||||
'still calls your own number, so tell them '
|
||||
'yourself if the handover matters.',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12,
|
||||
height: 1.45,
|
||||
|
||||
@@ -591,13 +591,16 @@ class _ContactCard extends StatelessWidget {
|
||||
if (handover.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
// Recorded, not relayed. `remarks` reaches the
|
||||
// console and stops: the rider app reads a `notes`
|
||||
// field the backend does not send.
|
||||
who.isEmpty
|
||||
? '$handover is handing it over. We will pass '
|
||||
'this on — your Miler still calls the '
|
||||
? '$handover is handing it over. Noted on your '
|
||||
'booking — your Miler still calls the '
|
||||
'number above.'
|
||||
: '$who ($handover) is handing it over. We will '
|
||||
'pass this on — your Miler still calls the '
|
||||
'number above.',
|
||||
: '$who ($handover) is handing it over. Noted on '
|
||||
'your booking — your Miler still calls '
|
||||
'the number above.',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
height: 1.45,
|
||||
|
||||
Reference in New Issue
Block a user