Compare commits
2 Commits
c3e25feaea
...
c7a74c57b8
| Author | SHA1 | Date | |
|---|---|---|---|
| c7a74c57b8 | |||
| 8757b16cf5 |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 110 KiB |
BIN
design/screens/02-create-account.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 152 KiB |
BIN
design/screens/03-enter-pin.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 418 KiB After Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 259 KiB |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -124,10 +124,22 @@ class MapTileProvider {
|
||||
keyParam: 'api_key',
|
||||
);
|
||||
|
||||
/// OpenStreetMap's own tile servers.
|
||||
/// OpenStreetMap's own tile servers. **The app's default.**
|
||||
///
|
||||
/// Their usage policy does not permit a distributed app to lean on these —
|
||||
/// present for local development and as a last-resort fallback only.
|
||||
/// ── One thing to know before this ships ──
|
||||
///
|
||||
/// These are donated servers, and the OSM Foundation's tile usage policy
|
||||
/// does not permit a distributed app to lean on them. It asks for a valid
|
||||
/// identifying User-Agent (we send one — see `DmMapTiles.layer`), no bulk
|
||||
/// downloading, and it reserves the right to block traffic that grows past
|
||||
/// what a hobby project would make. A block looks like every tile in the app
|
||||
/// turning into the ground colour at once, with no other symptom.
|
||||
///
|
||||
/// Nothing in the app needs to change when that becomes a problem: set
|
||||
/// `DM_MAP_PROVIDER` to `carto`, `maptiler` or `stadia` — all three serve
|
||||
/// OpenStreetMap data and the map looks near enough the same — and add the
|
||||
/// provider's key as `DM_MAP_KEY`. The attribution line follows the provider
|
||||
/// on its own, so nothing else is touched.
|
||||
static const osm = MapTileProvider(
|
||||
id: 'osm',
|
||||
name: 'OpenStreetMap standard tiles',
|
||||
@@ -237,15 +249,17 @@ class DmMapConfig {
|
||||
);
|
||||
|
||||
factory DmMapConfig.fromEnvironment() {
|
||||
final id = _provider.isEmpty ? 'carto' : _provider;
|
||||
// OpenStreetMap unless a build says otherwise. See [MapTileProvider.osm]
|
||||
// for what that commits us to, and how to move off it in one define.
|
||||
final id = _provider.isEmpty ? 'osm' : _provider;
|
||||
String? warning;
|
||||
|
||||
MapTileProvider provider;
|
||||
if (id == 'custom') {
|
||||
if (_url.isEmpty) {
|
||||
provider = MapTileProvider.carto;
|
||||
provider = MapTileProvider.osm;
|
||||
warning =
|
||||
'DM_MAP_PROVIDER=custom needs DM_MAP_URL; fell back to CARTO.';
|
||||
'DM_MAP_PROVIDER=custom needs DM_MAP_URL; fell back to OpenStreetMap.';
|
||||
} else {
|
||||
provider = MapTileProvider(
|
||||
id: 'custom',
|
||||
@@ -268,9 +282,9 @@ class DmMapConfig {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
provider = MapTileProvider.presets[id] ?? MapTileProvider.carto;
|
||||
provider = MapTileProvider.presets[id] ?? MapTileProvider.osm;
|
||||
if (!MapTileProvider.presets.containsKey(id)) {
|
||||
warning = 'Unknown DM_MAP_PROVIDER "$id"; fell back to CARTO.';
|
||||
warning = 'Unknown DM_MAP_PROVIDER "$id"; fell back to OpenStreetMap.';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -976,7 +1044,7 @@ class DestinationGroup {
|
||||
return {
|
||||
...destination.toJson(),
|
||||
'packageCount': packageCount,
|
||||
// Omitted rather than sent empty: One Touch fills none of this in, and
|
||||
// Omitted rather than sent empty: BOOK NOW fills none of this in, and
|
||||
// `details: {}` is a key that says nothing.
|
||||
if (detail.isNotEmpty) 'details': detail,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
@@ -426,7 +463,7 @@ class AppState extends ChangeNotifier {
|
||||
///
|
||||
/// ── Two ways to book the same parcel ──
|
||||
///
|
||||
/// **One Touch** asks three things: the state, the district and a pickup
|
||||
/// **BOOK NOW** asks three things: the state, the district and a pickup
|
||||
/// window. Nothing else — no door number, no recipient, no weight. Those are
|
||||
/// filled in by the Miler standing at the address with the parcel in their
|
||||
/// hand, which is the only moment anybody actually knows them.
|
||||
|
||||
@@ -7,11 +7,27 @@ import '../../widgets/feedback.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import 'auth_scaffold.dart';
|
||||
import 'otp_screen.dart';
|
||||
import 'signup_screen.dart';
|
||||
import 'pin_screen.dart';
|
||||
|
||||
enum LoginMode { phone, email }
|
||||
|
||||
/// Sign in — phone or email, then a 4-digit code.
|
||||
/// Sign in — a phone number and a PIN, or an email and a code.
|
||||
///
|
||||
/// ── Why the two halves work differently ──
|
||||
///
|
||||
/// They used to be the same: both sent a 4-digit code. The SMS gateway was
|
||||
/// then switched off, and `POST /auth/otp/request` does not fail when that
|
||||
/// happens — it still answers `sent: true` and still issues a valid code,
|
||||
/// writing it to the **server log** instead of sending it. So the phone path
|
||||
/// walked customers to four boxes for a code that could not arrive, and every
|
||||
/// value they typed was wrong.
|
||||
///
|
||||
/// Phone now goes to [PinScreen]: a PIN the customer chooses needs no gateway.
|
||||
///
|
||||
/// **Email still sends a code, and still works** — it goes over SMTP, which is
|
||||
/// unaffected — so that path is untouched. It is kept rather than removed
|
||||
/// because deleting a working way in to tidy up a broken one is a net loss for
|
||||
/// anyone who has an email on their account.
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@@ -46,6 +62,33 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
final raw = _identifier.text.trim();
|
||||
setState(() => _sending = true);
|
||||
final target = _isPhone ? '+91 $raw' : raw;
|
||||
final navigator = Navigator.of(context);
|
||||
|
||||
// ── A phone number does not get a code any more ──
|
||||
//
|
||||
// `POST /auth/login` says which of the three PIN screens this number
|
||||
// leads to, and [PinScreen] renders that one. Asking first is what stops
|
||||
// the app offering "enter your PIN" to somebody who has never set one.
|
||||
if (_isPhone) {
|
||||
try {
|
||||
final check = await AppScope.read(context).checkPhone(target);
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
await navigator.push(
|
||||
MaterialPageRoute<void>(builder: (_) => PinScreen(check: check)),
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
DmToast.show(context, e.message);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
DmToast.show(context, 'Something went wrong. Please try again.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final challenge = await AppScope.read(context).sendOtp(target);
|
||||
if (!mounted) return;
|
||||
@@ -63,8 +106,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
if (!challenge.sent) {
|
||||
DmToast.show(
|
||||
context,
|
||||
'We could not send a code to that ${_isPhone ? 'number' : 'address'}. '
|
||||
'Try again in a moment.',
|
||||
'We could not send a code to that address. Try again in a moment.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -114,20 +156,14 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
// The CTA is pinned, not scrolled. In the scroll area it was clipped by
|
||||
// the footer the moment the keyboard came up — the one control the screen
|
||||
// exists for, hidden exactly when it is needed.
|
||||
footer: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AuthSwitchLink(
|
||||
question: 'New to Doormile?',
|
||||
action: 'Create account',
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const SignUpScreen()),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const AuthLegal(),
|
||||
],
|
||||
),
|
||||
// ── No "Create account" link ──
|
||||
//
|
||||
// It pushed a separate sign-up screen that asked for a name and then
|
||||
// sent an SMS code. A new number is now recognised by
|
||||
// `POST /auth/login` and [PinScreen] asks for the name and the PIN in
|
||||
// one step, so a second entrance would ask the same questions twice and
|
||||
// send a code that cannot arrive.
|
||||
footer: const AuthLegal(),
|
||||
children: [
|
||||
// No label above it. The heading already said what to type, and a
|
||||
// field captioned "Phone number" under a heading reading "Enter your
|
||||
@@ -163,7 +199,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
DmButton(
|
||||
label: 'Continue',
|
||||
busy: _sending,
|
||||
busyLabel: 'Sending code…',
|
||||
// The phone path sends nothing — it asks which screen comes next.
|
||||
busyLabel: _isPhone ? 'Checking…' : 'Sending code…',
|
||||
onPressed: _valid ? _continue : null,
|
||||
),
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ class _OtpScreenState extends State<OtpScreen> {
|
||||
try {
|
||||
await app.verifyOtp(widget.identifier, _digits, name: widget.name);
|
||||
if (!mounted) return;
|
||||
app.detectPickupLocation();
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
await navigator.pushAndRemoveUntil(
|
||||
MaterialPageRoute<void>(builder: (_) => const ShellScreen()),
|
||||
|
||||
290
lib/ui/screens/auth/pin_screen.dart
Normal file
@@ -0,0 +1,290 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../data/api_exception.dart';
|
||||
import '../../../data/app_config.dart';
|
||||
import '../../../data/models.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import '../shell_screen.dart';
|
||||
import 'auth_scaffold.dart';
|
||||
|
||||
/// The second step of phone sign-in: a 4-digit PIN.
|
||||
///
|
||||
/// ── Why this replaced the code screen ──
|
||||
///
|
||||
/// The SMS gateway was switched off. `POST /auth/otp/request` still answers
|
||||
/// `sent: true` and still issues a valid code — it writes it to the **server
|
||||
/// log** instead of sending it. So the code screen became four boxes nobody
|
||||
/// could ever fill: the customer waits for an SMS that cannot arrive, and
|
||||
/// everything they eventually type is wrong.
|
||||
///
|
||||
/// A PIN the customer chooses needs no gateway. Email OTP still works and is
|
||||
/// still offered on the screen before this one — this replaces the phone path
|
||||
/// only.
|
||||
///
|
||||
/// ── One screen, three entrances ──
|
||||
///
|
||||
/// `POST /auth/login` has already said which of these a number is, so this
|
||||
/// screen never guesses:
|
||||
///
|
||||
/// * [PhoneStep.enterPin] — a returning customer types theirs
|
||||
/// * [PhoneStep.createPin] — an account with no PIN yet chooses one
|
||||
/// * [PhoneStep.createAccount] — a new number gives a name and a PIN
|
||||
///
|
||||
/// Guessing is not a cosmetic risk. Ask a returning customer to invent a PIN
|
||||
/// and the server answers `pin_already_set`, leaving them on a screen that
|
||||
/// cannot succeed; ask a new customer for the PIN they have never set and
|
||||
/// every attempt is wrong.
|
||||
///
|
||||
/// ── What this screen does not say ──
|
||||
///
|
||||
/// `POST /auth/login` returns the account holder's **name**, and the backend
|
||||
/// has been asked to stop sending it, because it tells anybody who types a
|
||||
/// number who owns it. This screen does not greet the customer by it. A
|
||||
/// friendlier screen is not worth a free lookup of who owns a phone number.
|
||||
class PinScreen extends StatefulWidget {
|
||||
const PinScreen({super.key, required this.check});
|
||||
|
||||
/// The answer from `POST /auth/login` — which entrance this is.
|
||||
final PhoneCheck check;
|
||||
|
||||
@override
|
||||
State<PinScreen> createState() => _PinScreenState();
|
||||
}
|
||||
|
||||
class _PinScreenState extends State<PinScreen> {
|
||||
final _pin = TextEditingController();
|
||||
final _confirm = TextEditingController();
|
||||
final _name = TextEditingController();
|
||||
bool _busy = false;
|
||||
|
||||
/// Set when the server refuses, and cleared on the next keystroke, so the
|
||||
/// reason sits under the field the customer is fixing rather than in a toast
|
||||
/// that has gone by the time they look.
|
||||
String? _error;
|
||||
|
||||
PhoneStep get _step => widget.check.step;
|
||||
bool get _creating => _step != PhoneStep.enterPin;
|
||||
bool get _needsName => _step == PhoneStep.createAccount;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final c in [_pin, _confirm, _name]) {
|
||||
c.addListener(() => setState(() => _error = null));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pin.dispose();
|
||||
_confirm.dispose();
|
||||
_name.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _valid {
|
||||
if (_pin.text.length != 4) return false;
|
||||
if (!_creating) return true;
|
||||
if (_confirm.text != _pin.text) return false;
|
||||
return !_needsName || _name.text.trim().length >= 2;
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final app = AppScope.read(context);
|
||||
final navigator = Navigator.of(context);
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
if (_creating) {
|
||||
await app.setPin(
|
||||
phone: widget.check.phone,
|
||||
pin: _pin.text,
|
||||
name: _needsName ? _name.text.trim() : null,
|
||||
);
|
||||
} else {
|
||||
await app.verifyPin(phone: widget.check.phone, pin: _pin.text);
|
||||
}
|
||||
if (!mounted) return;
|
||||
// Everything below the sign-in screens goes, so back does not land on a
|
||||
// PIN field with a live session behind it.
|
||||
await navigator.pushAndRemoveUntil(
|
||||
MaterialPageRoute<void>(builder: (_) => const ShellScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = _reasonFor(e);
|
||||
});
|
||||
// ── The two that are not a typo ──
|
||||
//
|
||||
// `pin_not_set` and `pin_already_set` mean this screen is the wrong one
|
||||
// for this number — the account changed between the check and the
|
||||
// submit, or the check was wrong. Re-asking is the only recovery, and it
|
||||
// is one tap, so the screen says so and steps back rather than leaving
|
||||
// somebody retyping a PIN that can never be right.
|
||||
if (e.code == ApiException.pinNotSet ||
|
||||
e.code == ApiException.pinAlreadySet) {
|
||||
navigator.pop();
|
||||
}
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = 'Something went wrong. Please try again.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _reasonFor(ApiException e) => switch (e.code) {
|
||||
// Deliberately does not say which. The server answers the same for a wrong
|
||||
// PIN and an unknown number, and narrowing it here would turn sign-in into
|
||||
// a way of testing whether a number has an account.
|
||||
ApiException.invalidPin => 'That number or PIN is incorrect',
|
||||
// Not in the contract yet — the backend is adding a per-account lockout.
|
||||
// Its own message is the useful one, because only the server knows how
|
||||
// long is left.
|
||||
ApiException.pinLocked => e.message,
|
||||
ApiException.pinNotSet => 'This number has no PIN yet. Create one.',
|
||||
ApiException.pinAlreadySet => 'This number already has a PIN. Enter it.',
|
||||
_ => e.message,
|
||||
};
|
||||
|
||||
String get _title => switch (_step) {
|
||||
PhoneStep.enterPin => 'Enter your PIN',
|
||||
PhoneStep.createPin => 'Create a PIN',
|
||||
PhoneStep.createAccount => 'Create your account',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AuthScaffold(
|
||||
compactBanner: true,
|
||||
badge: false,
|
||||
title: _title,
|
||||
// ── A way back to the number ──
|
||||
//
|
||||
// Without this the only correction for a mistyped digit is killing the
|
||||
// app: the phone screen is behind this one and nothing on this one
|
||||
// returns to it. The customer can see the number they are signing in to
|
||||
// in the line below the title, which is exactly when they notice it is
|
||||
// wrong.
|
||||
onBack: _busy ? null : () => Navigator.of(context).maybePop(),
|
||||
footer: const AuthLegal(),
|
||||
children: [
|
||||
Text(
|
||||
_creating
|
||||
? 'A 4-digit PIN signs you in from now on. '
|
||||
'It is how you get back to ${widget.check.phone}.'
|
||||
: 'For ${widget.check.phone}.',
|
||||
style: DmText.small.copyWith(color: DmColors.ink3, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
if (_needsName) ...[
|
||||
DmTextField(
|
||||
controller: _name,
|
||||
hint: 'Your full name',
|
||||
keyboardType: TextInputType.name,
|
||||
maxLength: 60,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
DmTextField(
|
||||
controller: _pin,
|
||||
hint: _creating ? 'Choose a 4-digit PIN' : '4-digit PIN',
|
||||
keyboardType: TextInputType.number,
|
||||
digitsOnly: true,
|
||||
maxLength: 4,
|
||||
obscure: true,
|
||||
mono: true,
|
||||
autofocus: !_needsName,
|
||||
textInputAction: _creating
|
||||
? TextInputAction.next
|
||||
: TextInputAction.done,
|
||||
onSubmitted: (_) => _valid && !_creating ? _submit() : null,
|
||||
),
|
||||
|
||||
if (_creating) ...[
|
||||
const SizedBox(height: 12),
|
||||
DmTextField(
|
||||
controller: _confirm,
|
||||
hint: 'Enter it again',
|
||||
keyboardType: TextInputType.number,
|
||||
digitsOnly: true,
|
||||
maxLength: 4,
|
||||
obscure: true,
|
||||
mono: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _valid ? _submit() : null,
|
||||
),
|
||||
// Said before they choose, not after they forget. There is no reset
|
||||
// endpoint yet — see the support line below — so a forgotten PIN is
|
||||
// a phone call, and that is worth knowing at the moment of choosing
|
||||
// one rather than six weeks later.
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Pick something you will remember. '
|
||||
'Changing it later means contacting support.',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
height: 1.45,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_error!,
|
||||
style: DmText.small.copyWith(color: DmColors.brand, height: 1.45),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 18),
|
||||
DmButton(
|
||||
label: _creating ? 'Create PIN and continue' : 'Sign in',
|
||||
busy: _busy,
|
||||
busyLabel: _creating ? 'Creating…' : 'Signing in…',
|
||||
onPressed: _valid ? _submit : null,
|
||||
),
|
||||
|
||||
// ── Forgotten PINs have no self-service path ──
|
||||
//
|
||||
// The backend has no reset or change endpoint for customers, so this
|
||||
// cannot be a link that does something — it can only point at a human.
|
||||
// It renders **only when a support contact is configured**
|
||||
// (`AppConfig.supportPhone` / `supportEmail`, both empty by default),
|
||||
// because "contact support" with no way to contact them is worse than
|
||||
// silence: it tells somebody locked out that help exists and then does
|
||||
// not say where.
|
||||
if (!_creating && AppConfig.hasSupportContact) ...[
|
||||
const SizedBox(height: 14),
|
||||
Center(
|
||||
child: DmTextAction(
|
||||
label: 'Forgot your PIN?',
|
||||
onPressed: () => DmToast.show(
|
||||
context,
|
||||
AppConfig.supportPhone.isNotEmpty
|
||||
? 'Call ${AppConfig.supportPhone} and we will reset it'
|
||||
: 'Email ${AppConfig.supportEmail} and we will reset it',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../data/doormile_api.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import 'auth_scaffold.dart';
|
||||
import 'otp_screen.dart';
|
||||
|
||||
/// Create an account — name and phone, with email optional.
|
||||
///
|
||||
/// The same OTP screen verifies both sign in and sign up; the name is carried
|
||||
/// through so the new account is created with it.
|
||||
class SignUpScreen extends StatefulWidget {
|
||||
const SignUpScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SignUpScreen> createState() => _SignUpScreenState();
|
||||
}
|
||||
|
||||
class _SignUpScreenState extends State<SignUpScreen> {
|
||||
final _name = TextEditingController();
|
||||
final _phone = TextEditingController();
|
||||
final _email = TextEditingController();
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final c in [_name, _phone, _email]) {
|
||||
c.addListener(() => setState(() {}));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in [_name, _phone, _email]) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _valid =>
|
||||
_name.text.trim().length >= 2 && _phone.text.trim().length >= 10;
|
||||
|
||||
Future<void> _continue() async {
|
||||
setState(() => _sending = true);
|
||||
final phone = '+91 ${_phone.text.trim()}';
|
||||
try {
|
||||
final challenge = await AppScope.read(context).signUp(
|
||||
name: _name.text.trim(),
|
||||
phone: phone,
|
||||
email: _email.text.trim().isEmpty ? null : _email.text.trim(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => OtpScreen(
|
||||
identifier: phone,
|
||||
name: _name.text.trim(),
|
||||
challenge: challenge,
|
||||
),
|
||||
),
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
DmToast.show(context, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AuthScaffold(
|
||||
onBack: () => Navigator.of(context).maybePop(),
|
||||
// Same entrance as sign in: the headline sits on the brand field at
|
||||
// display size and the sheet starts straight into the form. Sign in and
|
||||
// sign up are one doorway with two doors, so they cannot be laid out
|
||||
// differently — a customer who bounces between them should see the
|
||||
// screen change its words, not its shape.
|
||||
// One line, no supporting sentence. "A few details and you can book
|
||||
// your first pickup" described the three fields directly underneath it,
|
||||
// on a screen whose whole content is those three fields.
|
||||
heroTitle: 'Create your account',
|
||||
badge: false,
|
||||
// Pinned for the same reason as sign in: a three-field form plus the
|
||||
// keyboard leaves no room, and the CTA must not be the thing that loses.
|
||||
footer: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DmButton(
|
||||
label: 'Continue',
|
||||
busy: _sending,
|
||||
busyLabel: 'Sending code…',
|
||||
onPressed: _valid ? _continue : null,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
AuthSwitchLink(
|
||||
question: 'Already have an account?',
|
||||
action: 'Sign in',
|
||||
onTap: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const AuthLegal(),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
// Floating labels, and no hints behind them: a caption above an empty
|
||||
// field and an example inside it are two ways of asking the same
|
||||
// question, and three of those stacked is six rows of chrome around
|
||||
// three answers. The label sits where the answer will go and floats
|
||||
// out once there is something to caption.
|
||||
DmTextField(
|
||||
label: 'Full name',
|
||||
floating: true,
|
||||
controller: _name,
|
||||
keyboardType: TextInputType.name,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
DmTextField(
|
||||
label: 'Phone number',
|
||||
floating: true,
|
||||
controller: _phone,
|
||||
prefix: '+91',
|
||||
keyboardType: TextInputType.phone,
|
||||
digitsOnly: true,
|
||||
maxLength: 10,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
DmTextField(
|
||||
label: 'Email',
|
||||
floating: true,
|
||||
optional: true,
|
||||
controller: _email,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _valid ? _continue() : null,
|
||||
),
|
||||
// ── The reassurance banner is gone ──
|
||||
//
|
||||
// "We verify every number · A 4-digit code confirms it's you and keeps
|
||||
// your parcels secure." It was the last thing in a scrolling list
|
||||
// under a pinned footer, so on a 720p phone the footer cut it in half
|
||||
// and the customer read "…keeps your parcels secu". A reassurance that
|
||||
// is clipped reassures nobody.
|
||||
//
|
||||
// It also answered a question nobody had yet: the customer finds out
|
||||
// about the code on the next screen, which is called "Verify your
|
||||
// number" and says so.
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -472,13 +472,22 @@ class _ConfirmSheet extends StatelessWidget {
|
||||
// ── Said here, not discovered later ──
|
||||
//
|
||||
// The rider's call button dials the account, and
|
||||
// nothing this app sends can change that. Better
|
||||
// to say so beside the field than to let somebody
|
||||
// hand their parcel to a neighbour believing the
|
||||
// Miler has the neighbour's number.
|
||||
// nothing this app sends can change that.
|
||||
//
|
||||
// This used to say "we pass this to your Miler as
|
||||
// a note", and that was wrong. `remarks` reaches
|
||||
// the admin console and stops there: the rider app
|
||||
// reads a `notes` field per stop and the backend
|
||||
// never sends one. A customer reading the old
|
||||
// sentence could hand their parcel to a neighbour
|
||||
// believing the Miler had been told, when nobody
|
||||
// in the field had. Corrected until the backend
|
||||
// carries a real handover contact — see
|
||||
// docs/BACKEND_CHANGES.md §6.
|
||||
Text(
|
||||
'We pass this to your Miler as a note. Their '
|
||||
'call button still dials your own number.',
|
||||
'We record this on your booking. Your Miler '
|
||||
'still calls your own number, so tell them '
|
||||
'yourself if the handover matters.',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12,
|
||||
height: 1.45,
|
||||
|
||||
@@ -266,7 +266,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
),
|
||||
// ── Only the long way asks for this ──
|
||||
//
|
||||
// One Touch books on a state, a district and a window; the door is
|
||||
// BOOK NOW books on a state, a district and a window; the door is
|
||||
// filled in by the Miler who is standing at it. A customer who chose
|
||||
// the full form is choosing to save them that step, so the row is
|
||||
// here, it is outstanding until it is answered, and the button waits
|
||||
@@ -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,
|
||||
|
||||
@@ -87,7 +87,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
// The full form asks for the door before it asks for the window, and it
|
||||
// asks once per destination: a visit that fans out to three places is
|
||||
// three addresses, and "for all parcels" is the whole point of choosing
|
||||
// this path over One Touch.
|
||||
// this path over BOOK NOW.
|
||||
if (detailed) {
|
||||
for (final group in app.draftDestinations) {
|
||||
if (!mounted) return;
|
||||
@@ -730,7 +730,7 @@ class _PickupLine extends StatelessWidget {
|
||||
///
|
||||
/// ── Why the app has two ways to start and only one of them is a sphere ──
|
||||
///
|
||||
/// One Touch is the product: a state, a district, a window, and the Miler
|
||||
/// BOOK NOW is the product: a state, a district, a window, and the Miler
|
||||
/// writes down the door when they get there. It is a sphere because it is one
|
||||
/// gesture, and a gesture cannot be a form.
|
||||
///
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../data/models.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
import '../tokens.dart';
|
||||
import '../widgets/pieces.dart';
|
||||
import '../widgets/inputs.dart';
|
||||
import '../widgets/states.dart';
|
||||
import 'order_row.dart';
|
||||
@@ -183,20 +183,22 @@ class _OrdersScreenState extends State<OrdersScreen> {
|
||||
///
|
||||
/// Scrollable because a fourth state is one backend release away and a row of
|
||||
/// four fixed chips at 1.8x text has nowhere to go. Each carries its count in
|
||||
/// the same pill, because "Active" and "4" are one fact.
|
||||
/// Active / Past / Cancelled, as a segmented control.
|
||||
///
|
||||
/// ── Chips → text tabs → a segment ──
|
||||
/// ── Chips → text tabs → a segment → the app's own segment ──
|
||||
///
|
||||
/// The chips were three containers, one of them filled solid black, sitting
|
||||
/// above a list of cards — the loudest object on the screen was the filter.
|
||||
/// Plain text tabs with a rule fixed that and left the row looking like a
|
||||
/// heading rather than a control.
|
||||
/// The chips were three containers, one filled solid black, sitting above a
|
||||
/// list of cards — the loudest object on the screen was the filter. Plain text
|
||||
/// tabs with a rule fixed that and left the row looking like a heading rather
|
||||
/// than a control. A groove with a raised chip in it was the right shape.
|
||||
///
|
||||
/// A groove with a raised chip in it is the shape that is unambiguously a
|
||||
/// control and still quiet: white lifted out of a warm bed, ink type, and the
|
||||
/// count beside the label because that is the reason anyone looks at the two
|
||||
/// tabs they are not on.
|
||||
/// This was then a *second* implementation of that shape: its own `_Tab`, a
|
||||
/// pill radius, 3pt of padding and the count folded into the label's text —
|
||||
/// beside the pickup window's day switcher, which is [DmChoiceChip] in a
|
||||
/// rounded groove with 4pt of padding and the count in a bubble. Two controls
|
||||
/// doing one job, drifting apart a padding value at a time.
|
||||
///
|
||||
/// It is the same control now. The count was already a parameter of it.
|
||||
class _Filter extends StatelessWidget {
|
||||
const _Filter({
|
||||
required this.labels,
|
||||
@@ -213,88 +215,28 @@ class _Filter extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.groove,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < labels.length; i++)
|
||||
Flexible(
|
||||
child: _Tab(
|
||||
label: labels[i],
|
||||
count: counts[i],
|
||||
selected: i == index,
|
||||
onTap: () => onChanged(i),
|
||||
),
|
||||
for (var i = 0; i < labels.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 4),
|
||||
DmChoiceChip(
|
||||
label: labels[i],
|
||||
// Zero is not a figure worth a bubble — an empty tab says so by
|
||||
// being empty when you open it.
|
||||
count: (counts[i] ?? 0) > 0 ? counts[i] : null,
|
||||
selected: i == index,
|
||||
expand: true,
|
||||
onTap: () => onChanged(i),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tab extends StatelessWidget {
|
||||
const _Tab({
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int? count;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onTap();
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
constraints: const BoxConstraints(minHeight: 34),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? DmColors.surface : Colors.transparent,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
boxShadow: selected ? DmShadow.card : null,
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: label),
|
||||
if (count != null && count! > 0)
|
||||
TextSpan(
|
||||
text: ' ($count)',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12,
|
||||
color: selected ? DmColors.ink3 : DmColors.ink4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 13,
|
||||
color: selected ? DmColors.ink : DmColors.ink3,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import '../../widgets/inputs.dart';
|
||||
///
|
||||
/// Every field on it is optional to the contract and every one of them is
|
||||
/// something the Miler can fill in at the door. That is the whole argument for
|
||||
/// One Touch. A customer who chooses the full form is choosing to save the
|
||||
/// BOOK NOW. A customer who chooses the full form is choosing to save the
|
||||
/// Miler that step, so the form has to be quick enough to be worth choosing:
|
||||
/// one surface, five fields, one button.
|
||||
///
|
||||
|
||||
@@ -9,7 +9,7 @@ import '../../widgets/feedback.dart';
|
||||
import 'destination_sheet.dart';
|
||||
import 'window_sheet.dart';
|
||||
|
||||
/// What ONE TOUCH asks: where, and when — on one surface.
|
||||
/// What BOOK NOW asks: where, and when — on one surface.
|
||||
///
|
||||
/// ── What this replaced ──
|
||||
///
|
||||
|
||||
@@ -60,7 +60,14 @@ class DmBookOrb extends StatefulWidget {
|
||||
const DmBookOrb({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
this.label = 'ONE TOUCH',
|
||||
// ── "BOOK NOW", not "ONE TOUCH" ──
|
||||
//
|
||||
// The old word named the *mechanism* — one gesture, no form — which is a
|
||||
// thing the product team knows and a customer has to be taught. Nobody
|
||||
// arrives at this screen wanting a touch; they want a parcel collected.
|
||||
// "BOOK NOW" says what pressing it does, and the caption underneath still
|
||||
// carries what makes it different from the form below.
|
||||
this.label = 'BOOK NOW',
|
||||
this.caption,
|
||||
this.held = false,
|
||||
this.field = maxField,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -131,7 +131,11 @@ class DmChoiceChip extends StatelessWidget {
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
constraints: const BoxConstraints(minHeight: 40),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
// 9, not 12. Three of these split a 390pt phone between them on the
|
||||
// Orders filter, and at 12 the widest label truncated to "Cancell…".
|
||||
// The pickup window's day switcher has two chips and acres of room, so
|
||||
// it loses nothing.
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 9),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? DmColors.surface : Colors.transparent,
|
||||
@@ -155,7 +159,7 @@ class DmChoiceChip extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
if (count != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
const SizedBox(width: 5),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -86,7 +92,7 @@ Future<void> serverAdvance(
|
||||
/// The sheet is left open: tests that are about the sheet itself go no
|
||||
/// further.
|
||||
Future<void> openSend(WidgetTester tester) async {
|
||||
await tester.tap(find.text('ONE TOUCH'));
|
||||
await tester.tap(find.text('BOOK NOW'));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
@@ -172,38 +178,85 @@ 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('BOOK NOW'), 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('BOOK NOW'), 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);
|
||||
|
||||
// All four digits arrived, so it verified and moved on.
|
||||
expect(find.text('ONE TOUCH'), findsOneWidget);
|
||||
// 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);
|
||||
|
||||
expect(find.text('BOOK NOW'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
@@ -212,7 +265,7 @@ void main() {
|
||||
|
||||
// Home greets by name, carries the sphere, and ends on the live card.
|
||||
expect(find.text('Hello, Joe'), findsOneWidget);
|
||||
expect(find.text('ONE TOUCH'), findsOneWidget);
|
||||
expect(find.text('BOOK NOW'), findsOneWidget);
|
||||
// The seeded active booking surfaces on Home. Its state is the uppercase
|
||||
// micro-label beside the destination, in the chip vocabulary — "Assigned",
|
||||
// not the rail's "Miler assigned".
|
||||
@@ -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);
|
||||
@@ -870,7 +934,7 @@ void main() {
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
|
||||
await signIn(tester); // login + OTP
|
||||
expect(find.text('ONE TOUCH'), findsOneWidget); // home
|
||||
expect(find.text('BOOK NOW'), findsOneWidget); // home
|
||||
|
||||
await openSend(tester); // the whole booking, one screen
|
||||
await pickCity(tester, 'Chennai');
|
||||
@@ -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
|
||||
@@ -943,15 +1014,19 @@ void main() {
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('Orders'));
|
||||
await settle(tester);
|
||||
// Each tab carries its own count — the figure is the rows that tab will
|
||||
// actually show, which for a collected multi-destination pickup is one
|
||||
// per destination. The count shares a paragraph with the label, so it is
|
||||
// a span rather than a Text of its own.
|
||||
// ── Label and count are separate widgets now ──
|
||||
//
|
||||
// The filter used to fold the figure into the label's own paragraph
|
||||
// ("Active (4)"), so this matched one span. It is [DmChoiceChip] now —
|
||||
// the same control as the pickup window's day switcher — which puts the
|
||||
// count in a bubble beside the label. Two Texts, not one string.
|
||||
//
|
||||
// The figure is still the rows that tab will show, which for a collected
|
||||
// multi-destination pickup is one per destination.
|
||||
expect(find.text('Active'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining(
|
||||
'Active (${state.entriesOf(BookingStatus.active).length})',
|
||||
),
|
||||
findsOneWidget,
|
||||
find.text('${state.entriesOf(BookingStatus.active).length}'),
|
||||
findsWidgets,
|
||||
);
|
||||
|
||||
// The tab row scrolls — three labels plus their counts do not fit a 390pt
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -217,7 +230,7 @@ void main() {
|
||||
// districts inside a state, then a separate window sheet. There are no
|
||||
// states to open now — the list is flat and the state is a heading — and
|
||||
// the window is the same sheet's second step.
|
||||
await tester.tap(find.text('ONE TOUCH'));
|
||||
await tester.tap(find.text('BOOK NOW'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '07-pickup-where');
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -53,13 +53,19 @@ void main() {
|
||||
});
|
||||
|
||||
group('resolved configuration', () {
|
||||
test('defaults to CARTO and identifies the app to the provider', () {
|
||||
test('defaults to OpenStreetMap and identifies the app to it', () {
|
||||
// ── The User-Agent is not decoration here ──
|
||||
//
|
||||
// OSM's tile usage policy asks a distributed app for a valid
|
||||
// identifying agent, and blocks traffic it cannot attribute. A block
|
||||
// looks like every tile turning into the ground colour at once, with no
|
||||
// other symptom — so this asserts the identification, not just the URL.
|
||||
final config = DmMapConfig.fromEnvironment();
|
||||
expect(config.provider.id, 'carto');
|
||||
expect(config.provider.id, 'osm');
|
||||
expect(config.warning, isNull);
|
||||
expect(config.userAgent, contains('Doormile/'));
|
||||
expect(config.userAgent, contains(DmMapConfig.packageName));
|
||||
expect(config.attribution, contains('CARTO'));
|
||||
expect(config.attribution, contains('OpenStreetMap'));
|
||||
});
|
||||
|
||||
test('is swappable without touching a screen', () {
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:doormile_cx/ui/widgets/states.dart';
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// A SHEET DOES NOT CHANGE SIZE WHILE IT IS OPENING
|
||||
///
|
||||
/// Tapping ONE TOUCH opened a tall sheet that snapped shorter a few frames
|
||||
/// Tapping BOOK NOW opened a tall sheet that snapped shorter a few frames
|
||||
/// later. Nothing was wrong with either height — the sheet was simply as tall
|
||||
/// as whatever [DmAsyncList] happened to be rendering, and that changes twice
|
||||
/// before the customer has done anything: four skeleton rows at 302pt, then
|
||||
|
||||
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 110 KiB |
BIN
test/snapshots/01b-create-account.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 152 KiB |
BIN
test/snapshots/02-pin.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 418 KiB After Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 259 KiB |
@@ -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'),
|
||||
|
||||