diff --git a/design/screens/01-sign-in.png b/design/screens/01-sign-in.png index bf1148c..89c785f 100644 Binary files a/design/screens/01-sign-in.png and b/design/screens/01-sign-in.png differ diff --git a/design/screens/02-create-account.png b/design/screens/02-create-account.png new file mode 100644 index 0000000..7e4be61 Binary files /dev/null and b/design/screens/02-create-account.png differ diff --git a/design/screens/02-sign-up.png b/design/screens/02-sign-up.png deleted file mode 100644 index 1946ffe..0000000 Binary files a/design/screens/02-sign-up.png and /dev/null differ diff --git a/design/screens/03-enter-pin.png b/design/screens/03-enter-pin.png new file mode 100644 index 0000000..89b71a8 Binary files /dev/null and b/design/screens/03-enter-pin.png differ diff --git a/design/screens/03-verify-code.png b/design/screens/03-verify-code.png deleted file mode 100644 index 9b73e9e..0000000 Binary files a/design/screens/03-verify-code.png and /dev/null differ diff --git a/design/screens/04-home.png b/design/screens/04-home.png index c9daeaf..45f0660 100644 Binary files a/design/screens/04-home.png and b/design/screens/04-home.png differ diff --git a/design/screens/05-pickup-search.png b/design/screens/05-pickup-search.png index 5f48cfb..589fea0 100644 Binary files a/design/screens/05-pickup-search.png and b/design/screens/05-pickup-search.png differ diff --git a/design/screens/06-orders-active.png b/design/screens/06-orders-active.png index bf761c6..a332d76 100644 Binary files a/design/screens/06-orders-active.png and b/design/screens/06-orders-active.png differ diff --git a/design/screens/07-orders-completed.png b/design/screens/07-orders-completed.png index 63b8567..42db7a8 100644 Binary files a/design/screens/07-orders-completed.png and b/design/screens/07-orders-completed.png differ diff --git a/design/screens/11-pickup-where.png b/design/screens/11-pickup-where.png index 8b6eeb8..661d60a 100644 Binary files a/design/screens/11-pickup-where.png and b/design/screens/11-pickup-where.png differ diff --git a/design/screens/12-pickup-when.png b/design/screens/12-pickup-when.png index 281e5d5..e870396 100644 Binary files a/design/screens/12-pickup-when.png and b/design/screens/12-pickup-when.png differ diff --git a/design/screens/16-pickup-booked.png b/design/screens/16-pickup-booked.png index d50d1a8..6bf63ed 100644 Binary files a/design/screens/16-pickup-booked.png and b/design/screens/16-pickup-booked.png differ diff --git a/design/screens/17-receipt.png b/design/screens/17-receipt.png index a975e49..a1014a6 100644 Binary files a/design/screens/17-receipt.png and b/design/screens/17-receipt.png differ diff --git a/lib/data/api_exception.dart b/lib/data/api_exception.dart index d580e09..74a86d9 100644 --- a/lib/data/api_exception.dart +++ b/lib/data/api_exception.dart @@ -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, diff --git a/lib/data/dev_doormile_api.dart b/lib/data/dev_doormile_api.dart index 1eed656..6dbc696 100644 --- a/lib/data/dev_doormile_api.dart +++ b/lib/data/dev_doormile_api.dart @@ -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 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 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 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 diff --git a/lib/data/doormile_api.dart b/lib/data/doormile_api.dart index d41ae73..7a9ac9a 100644 --- a/lib/data/doormile_api.dart +++ b/lib/data/doormile_api.dart @@ -69,6 +69,33 @@ abstract class DoormileApi { /// [name] is set when verifying a freshly created account. Future 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 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 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 verifyPin({required String phone, required String pin}); + /// Restores a persisted session at launch, or null when there is none. Future restoreSession() async => null; diff --git a/lib/data/live_doormile_api.dart b/lib/data/live_doormile_api.dart index 7060864..ba27f56 100644 --- a/lib/data/live_doormile_api.dart +++ b/lib/data/live_doormile_api.dart @@ -120,6 +120,90 @@ class LiveDoormileApi extends DoormileApi { return customer; } + // ── PIN sign-in ── + + @override + Future 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 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 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 _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?) ?? 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 restoreSession() async { await _adoptDevTokenIfGiven(); diff --git a/lib/data/models.dart b/lib/data/models.dart index 2d9c547..f44fbbb 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -258,6 +258,74 @@ double? coordinate(Map 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 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, diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 3d585f2..4ecf81b 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -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 checkPhone(String phone) => api.checkPhone(phone); + + Future 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 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> searchPlaces(String query) { diff --git a/lib/ui/screens/auth/login_screen.dart b/lib/ui/screens/auth/login_screen.dart index 39f743b..e28ccf3 100644 --- a/lib/ui/screens/auth/login_screen.dart +++ b/lib/ui/screens/auth/login_screen.dart @@ -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 { 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(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 { 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 { // 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(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 { 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, ), diff --git a/lib/ui/screens/auth/otp_screen.dart b/lib/ui/screens/auth/otp_screen.dart index b9e8b73..75be59d 100644 --- a/lib/ui/screens/auth/otp_screen.dart +++ b/lib/ui/screens/auth/otp_screen.dart @@ -128,7 +128,6 @@ class _OtpScreenState extends State { try { await app.verifyOtp(widget.identifier, _digits, name: widget.name); if (!mounted) return; - app.detectPickupLocation(); unawaited(HapticFeedback.mediumImpact()); await navigator.pushAndRemoveUntil( MaterialPageRoute(builder: (_) => const ShellScreen()), diff --git a/lib/ui/screens/auth/pin_screen.dart b/lib/ui/screens/auth/pin_screen.dart new file mode 100644 index 0000000..ed73d28 --- /dev/null +++ b/lib/ui/screens/auth/pin_screen.dart @@ -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 createState() => _PinScreenState(); +} + +class _PinScreenState extends State { + 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 _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(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', + ), + ), + ), + ], + ], + ); + } +} diff --git a/lib/ui/screens/auth/signup_screen.dart b/lib/ui/screens/auth/signup_screen.dart deleted file mode 100644 index a95b132..0000000 --- a/lib/ui/screens/auth/signup_screen.dart +++ /dev/null @@ -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 createState() => _SignUpScreenState(); -} - -class _SignUpScreenState extends State { - 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 _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( - 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. - ], - ); - } -} diff --git a/lib/ui/screens/booking/pickup_location_screen.dart b/lib/ui/screens/booking/pickup_location_screen.dart index 4bc78ff..e1bd3d3 100644 --- a/lib/ui/screens/booking/pickup_location_screen.dart +++ b/lib/ui/screens/booking/pickup_location_screen.dart @@ -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, diff --git a/lib/ui/screens/booking/send_screen.dart b/lib/ui/screens/booking/send_screen.dart index b1efa23..22ae411 100644 --- a/lib/ui/screens/booking/send_screen.dart +++ b/lib/ui/screens/booking/send_screen.dart @@ -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, diff --git a/lib/ui/widgets/inputs.dart b/lib/ui/widgets/inputs.dart index f4154b9..af3e575 100644 --- a/lib/ui/widgets/inputs.dart +++ b/lib/ui/widgets/inputs.dart @@ -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 { 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, diff --git a/test/api_integration_test.dart b/test/api_integration_test.dart index 251d554..d19753d 100644 --- a/test/api_integration_test.dart +++ b/test/api_integration_test.dart @@ -41,6 +41,24 @@ class FakeApi extends DoormileApi { /// can make an earlier request land after a later one. final Map geocodes = {}; + // PIN sign-in is not what this file exercises; these satisfy the interface. + @override + Future checkPhone(String phone) async => + PhoneCheck(phone: phone, registered: false, pinSet: false); + + @override + Future setPin({ + required String phone, + required String pin, + String? name, + }) async => Customer(id: 'c', name: name ?? '', phone: phone, email: ''); + + @override + Future verifyPin({ + required String phone, + required String pin, + }) async => Customer(id: 'c', name: '', phone: phone, email: ''); + @override Future getBookingLimits({Place? pickup}) async { if (limitsError != null) throw limitsError!; diff --git a/test/booking_flow_test.dart b/test/booking_flow_test.dart index b8fef02..daf7594 100644 --- a/test/booking_flow_test.dart +++ b/test/booking_flow_test.dart @@ -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 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(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 diff --git a/test/design_snapshot_test.dart b/test/design_snapshot_test.dart index 90aeab8..2d19f0f 100644 --- a/test/design_snapshot_test.dart +++ b/test/design_snapshot_test.dart @@ -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'); diff --git a/test/login_otp_guard_test.dart b/test/login_otp_guard_test.dart index f0fad3f..4b5d9bd 100644 --- a/test/login_otp_guard_test.dart +++ b/test/login_otp_guard_test.dart @@ -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 _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', ); diff --git a/test/snapshots/01-sign-in.png b/test/snapshots/01-sign-in.png index bf1148c..89c785f 100644 Binary files a/test/snapshots/01-sign-in.png and b/test/snapshots/01-sign-in.png differ diff --git a/test/snapshots/01b-create-account.png b/test/snapshots/01b-create-account.png new file mode 100644 index 0000000..7e4be61 Binary files /dev/null and b/test/snapshots/01b-create-account.png differ diff --git a/test/snapshots/01b-sign-up.png b/test/snapshots/01b-sign-up.png deleted file mode 100644 index 1946ffe..0000000 Binary files a/test/snapshots/01b-sign-up.png and /dev/null differ diff --git a/test/snapshots/02-pin.png b/test/snapshots/02-pin.png new file mode 100644 index 0000000..89b71a8 Binary files /dev/null and b/test/snapshots/02-pin.png differ diff --git a/test/snapshots/02-verify.png b/test/snapshots/02-verify.png deleted file mode 100644 index 9b73e9e..0000000 Binary files a/test/snapshots/02-verify.png and /dev/null differ diff --git a/test/snapshots/03-home.png b/test/snapshots/03-home.png index c9daeaf..45f0660 100644 Binary files a/test/snapshots/03-home.png and b/test/snapshots/03-home.png differ diff --git a/test/snapshots/03b-pickup-search.png b/test/snapshots/03b-pickup-search.png index 5f48cfb..589fea0 100644 Binary files a/test/snapshots/03b-pickup-search.png and b/test/snapshots/03b-pickup-search.png differ diff --git a/test/snapshots/04-orders.png b/test/snapshots/04-orders.png index bf761c6..a332d76 100644 Binary files a/test/snapshots/04-orders.png and b/test/snapshots/04-orders.png differ diff --git a/test/snapshots/04b-orders-completed.png b/test/snapshots/04b-orders-completed.png index 63b8567..42db7a8 100644 Binary files a/test/snapshots/04b-orders-completed.png and b/test/snapshots/04b-orders-completed.png differ diff --git a/test/snapshots/07-pickup-where.png b/test/snapshots/07-pickup-where.png index 8b6eeb8..661d60a 100644 Binary files a/test/snapshots/07-pickup-where.png and b/test/snapshots/07-pickup-where.png differ diff --git a/test/snapshots/07b-pickup-when.png b/test/snapshots/07b-pickup-when.png index 281e5d5..e870396 100644 Binary files a/test/snapshots/07b-pickup-when.png and b/test/snapshots/07b-pickup-when.png differ diff --git a/test/snapshots/11-booked.png b/test/snapshots/11-booked.png index d50d1a8..6bf63ed 100644 Binary files a/test/snapshots/11-booked.png and b/test/snapshots/11-booked.png differ diff --git a/test/snapshots/12-receipt.png b/test/snapshots/12-receipt.png index a975e49..a1014a6 100644 Binary files a/test/snapshots/12-receipt.png and b/test/snapshots/12-receipt.png differ diff --git a/tool/screens.sh b/tool/screens.sh index 632ab57..86d892a 100755 --- a/tool/screens.sh +++ b/tool/screens.sh @@ -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'),