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

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