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:
2026-09-30 10:44:22 +05:30
parent c3e25feaea
commit 8757b16cf5
44 changed files with 859 additions and 221 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 259 KiB

View File

@@ -49,6 +49,28 @@ class ApiException implements Exception {
/// The Miler has already arrived, so the cancel window has closed.
static const String notCancellable = 'not_cancellable';
// ── PIN sign-in ──
//
// Three codes, three different next screens, which is why none of them can
// be folded into [invalid]. The server uses the same message for a wrong PIN
// and an unknown number on purpose — so the app must not guess which, and
// says "That number or PIN is incorrect" rather than naming one.
/// Wrong PIN, **or** a phone with no account. Deliberately ambiguous.
static const String invalidPin = 'invalid_pin';
/// The account exists and has no PIN yet — send them to create one.
static const String pinNotSet = 'pin_not_set';
/// The account already has a PIN — send them to enter it.
static const String pinAlreadySet = 'pin_already_set';
/// Too many wrong PINs on this account. Not in the contract yet: the backend
/// is adding a per-account lockout, and the app reads it the moment it lands
/// rather than needing a release to catch up. Until then the server answers
/// [invalidPin] and this simply never fires.
static const String pinLocked = 'pin_locked';
/// Translates the contract's `error.code` into the vocabulary above.
///
/// The wire spells its codes in capitals; the client's are lowercase, and
@@ -64,6 +86,10 @@ class ApiException implements Exception {
if (trimmed.isEmpty) return fromStatus();
if (trimmed != trimmed.toUpperCase()) return trimmed;
return switch (trimmed) {
'INVALID_PIN' => invalidPin,
'PIN_NOT_SET' => pinNotSet,
'PIN_ALREADY_SET' => pinAlreadySet,
'PIN_LOCKED' || 'ACCOUNT_LOCKED' => pinLocked,
'UNAUTHORIZED' || 'TOKEN_EXPIRED' => unauthorized,
'FORBIDDEN' => forbidden,
'NOT_FOUND' => notFound,

View File

@@ -216,6 +216,95 @@ class DevDoormileApi extends DoormileApi {
);
});
// ── PIN sign-in ──
//
// Modelled on the real server's three answers rather than always succeeding,
// so the screens can be driven through every branch without a backend:
//
// 9876543210 an account with a PIN -> enterPin
// 9000000000 an account with no PIN -> createPin
// anything else -> createAccount
//
// The PIN itself is 1234 everywhere. Any other value is refused, so the
// wrong-PIN path is reachable too.
/// Numbers the fake backend already knows about, and whether they have a PIN.
static const _knownPins = {'9876543210': true, '9000000000': false};
static String _digits(String phone) => phone.replaceAll(RegExp(r'\D'), '');
static String _last10(String phone) {
final d = _digits(phone);
return d.length <= 10 ? d : d.substring(d.length - 10);
}
@override
Future<PhoneCheck> checkPhone(String phone) => _respond(() {
final key = _last10(phone);
final known = _knownPins[key];
return PhoneCheck(
phone: '+91 $key',
registered: known != null,
pinSet: known ?? false,
name: known == null ? null : 'Joe Oommen',
);
});
@override
Future<Customer> setPin({
required String phone,
required String pin,
String? name,
}) => _respond(() {
if (pin.length != 4 || int.tryParse(pin) == null) {
throw ApiException(ApiException.invalid, 'Enter a valid PIN');
}
final key = _last10(phone);
if (_knownPins[key] == true) {
throw ApiException(
ApiException.pinAlreadySet,
'That number already has a PIN',
);
}
if (_knownPins[key] == null && (name == null || name.trim().length < 2)) {
throw ApiException(ApiException.invalidName, 'Enter your full name');
}
return Customer(
id: 'cust_10241',
name: name?.trim().isNotEmpty == true ? name!.trim() : 'Joe Oommen',
phone: '+91 $key',
email: 'joe@example.com',
);
});
@override
Future<Customer> verifyPin({
required String phone,
required String pin,
}) => _respond(() {
final key = _last10(phone);
if (_knownPins[key] != true) {
throw ApiException(
ApiException.pinNotSet,
'Create a PIN for this number',
);
}
if (pin != '1234') {
// The server answers the same way for a wrong PIN and an unknown number,
// and so does this — the screen must not be able to tell them apart.
throw ApiException(
ApiException.invalidPin,
'That phone number or PIN is incorrect',
);
}
return const Customer(
id: 'cust_10241',
name: 'Joe Oommen',
phone: '+91 9876543210',
email: 'joe@example.com',
);
});
// ----------------------------------------------------------- serviceability
@override

View File

@@ -69,6 +69,33 @@ abstract class DoormileApi {
/// [name] is set when verifying a freshly created account.
Future<Customer> verifyOtp(String identifier, String code, {String? name});
// ── PIN sign-in ──
//
// The paid SMS gateway was switched off, so phone OTP issues codes that
// reach only the server log. These three replace it for phone numbers. Email
// OTP still works and is still offered — see `LoginScreen`.
//
// `setPin` and `verifyPin` return the same session `verifyOtp` does, so
// everything after sign-in — token refresh, restore, logout — is untouched.
/// Which of the three PIN screens this number leads to.
Future<PhoneCheck> checkPhone(String phone);
/// Sets the **first** PIN on a number, creating the account when it is new.
///
/// [name] is required only for a number with no account. Throws
/// [ApiException.pinAlreadySet] when there is already a PIN.
Future<Customer> setPin({
required String phone,
required String pin,
String? name,
});
/// Signs in with an existing PIN. Throws [ApiException.invalidPin] for a
/// wrong PIN *or* an unknown number, and [ApiException.pinNotSet] when the
/// account has none yet.
Future<Customer> verifyPin({required String phone, required String pin});
/// Restores a persisted session at launch, or null when there is none.
Future<Customer?> restoreSession() async => null;

View File

@@ -120,6 +120,90 @@ class LiveDoormileApi extends DoormileApi {
return customer;
}
// ── PIN sign-in ──
@override
Future<PhoneCheck> checkPhone(String phone) async {
final normalised = _normalisePhone(phone);
final response = await client.post(
'/auth/login',
body: {'phone': normalised},
authenticated: false,
);
return PhoneCheck.fromJson(response.map, normalised);
}
@override
Future<Customer> setPin({
required String phone,
required String pin,
String? name,
}) async {
final response = await client.post(
'/auth/set-pin',
// `new_pin`, snake_case, unlike every other field on the customer
// surface. The PIN routes were added on their own and spell it that way;
// sending `newPin` is a 400 "Enter a valid PIN" on every attempt.
body: {
'phone': _normalisePhone(phone),
'new_pin': pin,
if (name != null && name.trim().isNotEmpty) 'name': name.trim(),
},
authenticated: false,
idempotencyKey: client.newIdempotencyKey(),
);
return _adoptSessionFrom(response, 'set-pin');
}
@override
Future<Customer> verifyPin({
required String phone,
required String pin,
}) async {
final response = await client.post(
'/auth/verify-pin',
body: {'phone': _normalisePhone(phone), 'pin': pin},
authenticated: false,
idempotencyKey: client.newIdempotencyKey(),
);
return _adoptSessionFrom(response, 'verify-pin');
}
/// Reads a session out of an auth response and adopts it.
///
/// Extracted so the three sign-in paths cannot drift: `verifyOtp` had this
/// inline, and a second copy written by hand is a second place for the
/// `expiresIn` default or the null check to be subtly different.
Future<Customer> _adoptSessionFrom(ApiResponse response, String where) async {
final data = response.map;
final access = data['accessToken'] as String?;
final refresh = data['refreshToken'] as String?;
if (access == null || refresh == null) {
debugPrint('[AUTH] $where answered without a session');
throw ApiException(
ApiException.serverError,
'Something went wrong',
200,
response.requestId,
);
}
final customer = Customer.fromJson(
(data['customer'] as Map<String, dynamic>?) ?? const {},
);
await client.adoptSession(
Session(
accessToken: access,
refreshToken: refresh,
expiresAt: DateTime.now().add(
Duration(seconds: (data['expiresIn'] as num?)?.toInt() ?? 3600),
),
customer: customer,
),
);
return customer;
}
@override
Future<Customer?> restoreSession() async {
await _adoptDevTokenIfGiven();

View File

@@ -258,6 +258,74 @@ double? coordinate(Map<String, dynamic> json, String key) {
/// The server owns both numbers, and the OTP screen is built from them rather
/// than from constants: a backend that moves to a six-digit code, or lengthens
/// the resend window, must not need an app release to be usable.
/// What `POST /customer/auth/login` answers about a phone number.
///
/// ── Why the app asks before it shows a field ──
///
/// PIN sign-in has three entrances and they look identical to a customer: an
/// unknown number, a known number with no PIN yet, and a known number with
/// one. Guessing wrong means asking somebody to "enter your PIN" when they
/// have never set one, or asking a returning customer to invent a new one over
/// the top of theirs (which the server refuses with `pin_already_set`, leaving
/// them stuck on a screen that cannot succeed).
///
/// One call up front removes the guess. The screen that follows is the right
/// one every time.
class PhoneCheck {
const PhoneCheck({
required this.phone,
required this.registered,
required this.pinSet,
this.name,
});
/// The number as the server normalised it — E.164. Sent back on the next
/// call rather than re-normalised here, so both requests agree.
final String phone;
final bool registered;
final bool pinSet;
/// The account holder's first name, when there is an account.
///
/// The server returns it and the backend has been asked to stop, because it
/// discloses who owns a number to anybody who types it. The app therefore
/// **does not display it** — it is read only so that it disappears quietly
/// when the backend drops the field, rather than becoming a missing greeting
/// somebody has to go and diagnose.
final String? name;
/// Which screen comes next.
PhoneStep get step => !registered
? PhoneStep.createAccount
: (pinSet ? PhoneStep.enterPin : PhoneStep.createPin);
factory PhoneCheck.fromJson(Map<String, dynamic> json, String fallback) =>
PhoneCheck(
phone: (json['phone'] as String?)?.trim().isNotEmpty == true
? (json['phone'] as String).trim()
: fallback,
// snake_case here and nowhere else on the customer surface — the PIN
// routes were added separately and spell it `pin_set`. Both are read
// so a later tidy-up on the server does not break sign-in.
registered: (json['registered'] ?? json['isRegistered']) == true,
pinSet: (json['pin_set'] ?? json['pinSet']) == true,
name: (json['name'] as String?)?.trim(),
);
}
/// The screen a phone number leads to.
enum PhoneStep {
/// No account: ask for a name and a new PIN.
createAccount,
/// Account exists, no PIN: ask for a new PIN only.
createPin,
/// Account with a PIN: ask for it.
enterPin,
}
class OtpChallenge {
const OtpChallenge({
this.codeLength = 4,

View File

@@ -81,7 +81,17 @@ class AppState extends ChangeNotifier {
void signIn(Customer c) {
customer = c;
notifyListeners();
unawaited(refreshOrders());
// ── Every sign-in does the same three things ──
//
// This used to start `refreshOrders` alone, 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
// arrived, did not know about the extra call, and Home opened with no
// pickup and no serviceable cities.
//
// A sign-in is a sign-in wherever it happened, so the work belongs here
// and not in whichever screen happened to be last.
_afterSignIn();
}
/// Looks for a persisted session at launch.
@@ -340,6 +350,33 @@ class AppState extends ChangeNotifier {
return verified;
}
// ── PIN sign-in ──
//
// Same shape as [verifyOtp]: the session is adopted here so no screen has to
// remember to call [signIn] afterwards, and every sign-in path ends in the
// same place.
Future<PhoneCheck> checkPhone(String phone) => api.checkPhone(phone);
Future<Customer> setPin({
required String phone,
required String pin,
String? name,
}) async {
final created = await api.setPin(phone: phone, pin: pin, name: name);
signIn(created);
return created;
}
Future<Customer> verifyPin({
required String phone,
required String pin,
}) async {
final verified = await api.verifyPin(phone: phone, pin: pin);
signIn(verified);
return verified;
}
/// Biased towards the pickup point the customer is working with, so a search
/// for a street name answers with the one in their city first.
Future<List<Place>> searchPlaces(String query) {

View File

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

View File

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

View 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',
),
),
),
],
],
);
}
}

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ class DmTextField extends StatefulWidget {
this.autofocus = false,
this.digitsOnly = false,
this.mono = false,
this.obscure = false,
this.onClear,
this.floating = false,
});
@@ -44,6 +45,11 @@ class DmTextField extends StatefulWidget {
final TextEditingController controller;
final String? hint;
final bool optional;
/// Masks what is typed. For a PIN, which is a credential a customer may be
/// entering with somebody stood next to them — and which, unlike a posted
/// OTP, they will keep using.
final bool obscure;
final TextInputType? keyboardType;
final int? maxLength;
final int maxLines;
@@ -187,6 +193,11 @@ class _DmTextFieldState extends State<DmTextField> {
child: TextField(
controller: widget.controller,
focusNode: _node,
obscureText: widget.obscure,
// Masked fields default to a dot the mono face does not
// carry, which renders as a box. A bullet is in every
// family the app bundles.
obscuringCharacter: '•',
keyboardType: widget.keyboardType,
maxLines: widget.maxLines,
autofocus: widget.autofocus,

View File

@@ -41,6 +41,24 @@ class FakeApi extends DoormileApi {
/// can make an earlier request land after a later one.
final Map<double, (Duration, Place)> geocodes = {};
// PIN sign-in is not what this file exercises; these satisfy the interface.
@override
Future<PhoneCheck> checkPhone(String phone) async =>
PhoneCheck(phone: phone, registered: false, pinSet: false);
@override
Future<Customer> setPin({
required String phone,
required String pin,
String? name,
}) async => Customer(id: 'c', name: name ?? '', phone: phone, email: '');
@override
Future<Customer> verifyPin({
required String phone,
required String pin,
}) async => Customer(id: 'c', name: '', phone: phone, email: '');
@override
Future<BookingLimits> getBookingLimits({Place? pickup}) async {
if (limitsError != null) throw limitsError!;

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:doormile_cx/ui/widgets/inputs.dart';
import 'package:doormile_cx/data/doormile_api.dart';
import 'package:doormile_cx/data/location_service.dart';
import 'package:doormile_cx/data/models.dart';
@@ -54,11 +55,16 @@ Future<void> signIn(WidgetTester tester, {AppState? state}) async {
await tester.tap(find.text('Continue'));
await settle(tester);
// One field, not four boxes, and it verifies as soon as the fourth digit
// lands. Entered in one go on purpose: four separate fields dropped a digit
// whenever a keystroke arrived while focus was moving between them, which is
// what typing at any normal speed does.
await tester.enterText(find.byType(TextField).first, '1111');
// ── A PIN now, not a posted code ──
//
// The SMS gateway is off: `otp/request` still answers `sent: true` and
// writes the code to the server log, so the old four-box screen could never
// be completed by a customer. 9876543210 is a number the fake backend knows
// with a PIN already set, so this walks the returning-customer path and the
// button submits rather than the field auto-verifying.
await tester.enterText(find.byType(TextField).first, '1234');
await settle(tester, 200);
await tester.tap(find.text('Sign in'));
await settle(tester);
}
@@ -172,37 +178,84 @@ void main() {
tearDown(() => DoormileApi.overrideInstance(null));
testWidgets('the code is one field, typed a digit at a time',
testWidgets('a phone number is asked about before a PIN is asked for',
(tester) async {
// The regression this locks: the code used to be four `TextField`s that
// passed focus along, and a keystroke arriving mid-transition was dropped.
// The customer typed four digits, three arrived, and the screen answered
// "That code did not match" — blaming them for its own race.
// ── The regression this locks ──
//
// PIN sign-in has three entrances that look identical to a customer: an
// unknown number, a known number with no PIN, and a known number with one.
// The app asks `POST /auth/login` which it is, and renders that screen.
//
// 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 they make is wrong.
tester.view.physicalSize = const Size(1230, 9000);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(const DoormileApp());
// The splash holds for its own minimum — and the waits in front of it — before handing over, so
// the entrance is waited out once, here, rather than by lengthening every
// settle in the suite.
await settle(tester, 4400);
// A number the fake backend knows, with a PIN already set.
await tester.enterText(find.byType(TextField).first, '9876543210');
await settle(tester, 200);
await tester.tap(find.text('Continue'));
await settle(tester);
// One field. Nothing to pass focus between, so nothing to drop it in.
expect(find.text('Enter your PIN'), findsOneWidget);
// One field, and it is masked — a PIN is a credential the customer keeps,
// unlike a posted code they use once.
expect(find.byType(TextField), findsOneWidget);
expect(
tester.widget<DmTextField>(find.byType(DmTextField)).obscure,
isTrue,
);
final field = find.byType(TextField).first;
for (final sofar in ['1', '12', '123', '1234']) {
await tester.enterText(field, sofar);
await tester.pump();
}
// A wrong PIN says so without saying which of the two was wrong.
await tester.enterText(find.byType(TextField).first, '9999');
await settle(tester, 200);
await tester.tap(find.text('Sign in'));
await settle(tester);
expect(find.text('That number or PIN is incorrect'), findsOneWidget);
expect(find.text('ONE TOUCH'), findsNothing);
// The right one lands on Home.
await tester.enterText(find.byType(TextField).first, '1234');
await settle(tester, 200);
await tester.tap(find.text('Sign in'));
await settle(tester);
expect(find.text('ONE TOUCH'), findsOneWidget);
await drainToasts(tester);
});
testWidgets('a number with no account asks for a name and a new PIN',
(tester) async {
tester.view.physicalSize = const Size(1230, 9000);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(const DoormileApp());
await settle(tester, 4400);
await tester.enterText(find.byType(TextField).first, '9111122223');
await settle(tester, 200);
await tester.tap(find.text('Continue'));
await settle(tester);
// Name, PIN, confirm — the sign-up screen's questions, without a second
// screen and without an SMS that cannot arrive.
expect(find.text('Create your account'), findsOneWidget);
expect(find.byType(TextField), findsNWidgets(3));
final fields = find.byType(TextField);
await tester.enterText(fields.at(0), 'Meera S');
await tester.enterText(fields.at(1), '4821');
await tester.enterText(fields.at(2), '4821');
await settle(tester, 200);
await tester.tap(find.text('Create PIN and continue'));
await settle(tester);
// All four digits arrived, so it verified and moved on.
expect(find.text('ONE TOUCH'), findsOneWidget);
await drainToasts(tester);
});
@@ -819,7 +872,18 @@ void main() {
});
testWidgets('shows the network error state with retry', (tester) async {
await signIn(tester);
final state = AppState();
await signIn(tester, state: state);
// ── The cache has to be cold for the error to be reachable ──
//
// Signing in now warms the serviceable cities, and the destination sheet
// renders `cachedCities` as its `initialItems` rather than a skeleton — so
// with a warm cache and no network it shows the list it already has, which
// is the right behaviour and not what this test is about. Clearing the
// caches puts it back in the state a first-ever open is in.
state.statesCache = null;
state.districtCache.clear();
api.flags.networkError = true;
await openSend(tester);
@@ -895,7 +959,8 @@ void main() {
await drainToasts(tester);
});
testWidgets('sign up survives large accessibility text', (tester) async {
testWidgets('creating an account survives large accessibility text',
(tester) async {
tester.platformDispatcher.textScaleFactorTestValue = 1.8;
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
@@ -904,11 +969,17 @@ void main() {
addTearDown(tester.view.reset);
await tester.pumpWidget(const DoormileApp());
// The splash holds for its own minimum — and the waits in front of it — before handing over, so
// the entrance is waited out once, here, rather than by lengthening every
// settle in the suite.
await settle(tester, 4400);
await tester.tap(find.text('Create account'));
// ── There is no separate sign-up screen any more ──
//
// It asked for a name and then sent an SMS code, which cannot arrive with
// the gateway off. A new number is recognised by `POST /auth/login` and
// the PIN screen asks for the name and the PIN together, so this walks the
// real entrance instead of a "Create account" link that no longer exists.
await tester.enterText(find.byType(TextField).first, '9111122223');
await settle(tester, 200);
await tester.tap(find.text('Continue'));
await settle(tester);
// Building it is the assertion: any overflow in the hero or the form

View File

@@ -153,9 +153,18 @@ void main() {
await _settle(tester, 4400);
await _shot(tester, '01-sign-in');
await tester.tap(find.text('Create account'));
// ── Sign-in is a phone number and a PIN now ──
//
// The "Create account" link and its screen are gone: a new number is
// recognised by `POST /auth/login` and the PIN screen asks for the name
// and the PIN in one step. An unknown number therefore photographs the
// create-account state of that screen, and the known one photographs the
// returning-customer state.
await tester.enterText(find.byType(TextField).first, '9111122223');
await _settle(tester, 200);
await tester.tap(find.text('Continue'));
await _settle(tester);
await _shot(tester, '01b-sign-up');
await _shot(tester, '01b-create-account');
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await _settle(tester);
@@ -163,9 +172,13 @@ void main() {
await _settle(tester, 200);
await tester.tap(find.text('Continue'));
await _settle(tester);
await _shot(tester, '02-verify');
await _shot(tester, '02-pin');
await tester.enterText(find.byType(TextField).first, '1111');
// A PIN is submitted, not auto-verified: it is a credential the customer
// keeps, so the screen waits for them to say they are done.
await tester.enterText(find.byType(TextField).first, '1234');
await _settle(tester, 200);
await tester.tap(find.text('Sign in'));
await _settle(tester);
await _shot(tester, '03-home');

View File

@@ -8,6 +8,14 @@ import 'package:flutter_test/flutter_test.dart';
/// The login screen must not walk a customer to the code screen when no code
/// was sent.
///
/// ── Why this is an email test now ──
///
/// Phone sign-in no longer sends a code at all: the SMS gateway was switched
/// off, `otp/request` still answers `sent: true` and writes the code to the
/// server log, and the phone path therefore moved to a PIN. Email OTP goes
/// over SMTP, is unaffected, and is still offered — so the guard still matters
/// and this is the path that exercises it.
///
/// `sent: false` is the server saying it accepted the request and delivered
/// nothing — the SMS gateway or the mail relay refused it. Advancing anyway
/// puts four empty boxes in front of someone for a code that does not exist,
@@ -52,7 +60,10 @@ Future<void> _tapContinue(WidgetTester tester, AppState state) async {
// settle in the suite.
await settle(tester, 4400);
await tester.enterText(find.byType(TextField).first, '9876543210');
// Email, because that is the only identifier that still gets a code.
await tester.tap(find.text('Use email instead'));
await settle(tester, 200);
await tester.enterText(find.byType(TextField).first, 'joe@example.com');
await settle(tester, 200);
await tester.tap(find.text('Continue'));
await settle(tester);
@@ -74,7 +85,7 @@ void main() {
// instruction, a field and a button, so the heading is now the
// instruction. What this asserts is unchanged: the customer is still on
// the sign-in screen and can try again.
find.text('Enter your mobile number'),
find.text('Enter your email address'),
findsOneWidget,
reason: 'the customer stays where they can try again',
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

BIN
test/snapshots/02-pin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 259 KiB

View File

@@ -12,8 +12,8 @@ pairs = [
('00b-splash-invert.png', '00b-splash-invert.png'),
('00c-splash-mark.png', '00c-splash-mark.png'),
('01-sign-in.png', '01-sign-in.png'),
('01b-sign-up.png', '02-sign-up.png'),
('02-verify.png', '03-verify-code.png'),
('01b-create-account.png', '02-create-account.png'),
('02-pin.png', '03-enter-pin.png'),
('03-home.png', '04-home.png'),
('03b-pickup-search.png', '05-pickup-search.png'),
('04-orders.png', '06-orders-active.png'),