Compare commits
3 Commits
c3e25feaea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e2fe1b1771 | |||
| c7a74c57b8 | |||
| 8757b16cf5 |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 111 KiB |
BIN
design/screens/02-create-account.png
Normal file
|
After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 152 KiB |
BIN
design/screens/03-enter-pin.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 418 KiB After Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 304 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 252 KiB |
@@ -49,6 +49,28 @@ class ApiException implements Exception {
|
|||||||
/// The Miler has already arrived, so the cancel window has closed.
|
/// The Miler has already arrived, so the cancel window has closed.
|
||||||
static const String notCancellable = 'not_cancellable';
|
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.
|
/// Translates the contract's `error.code` into the vocabulary above.
|
||||||
///
|
///
|
||||||
/// The wire spells its codes in capitals; the client's are lowercase, and
|
/// 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.isEmpty) return fromStatus();
|
||||||
if (trimmed != trimmed.toUpperCase()) return trimmed;
|
if (trimmed != trimmed.toUpperCase()) return trimmed;
|
||||||
return switch (trimmed) {
|
return switch (trimmed) {
|
||||||
|
'INVALID_PIN' => invalidPin,
|
||||||
|
'PIN_NOT_SET' => pinNotSet,
|
||||||
|
'PIN_ALREADY_SET' => pinAlreadySet,
|
||||||
|
'PIN_LOCKED' || 'ACCOUNT_LOCKED' => pinLocked,
|
||||||
'UNAUTHORIZED' || 'TOKEN_EXPIRED' => unauthorized,
|
'UNAUTHORIZED' || 'TOKEN_EXPIRED' => unauthorized,
|
||||||
'FORBIDDEN' => forbidden,
|
'FORBIDDEN' => forbidden,
|
||||||
'NOT_FOUND' => notFound,
|
'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
|
// ----------------------------------------------------------- serviceability
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -69,6 +69,33 @@ abstract class DoormileApi {
|
|||||||
/// [name] is set when verifying a freshly created account.
|
/// [name] is set when verifying a freshly created account.
|
||||||
Future<Customer> verifyOtp(String identifier, String code, {String? name});
|
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.
|
/// Restores a persisted session at launch, or null when there is none.
|
||||||
Future<Customer?> restoreSession() async => null;
|
Future<Customer?> restoreSession() async => null;
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,90 @@ class LiveDoormileApi extends DoormileApi {
|
|||||||
return customer;
|
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
|
@override
|
||||||
Future<Customer?> restoreSession() async {
|
Future<Customer?> restoreSession() async {
|
||||||
await _adoptDevTokenIfGiven();
|
await _adoptDevTokenIfGiven();
|
||||||
|
|||||||
@@ -124,10 +124,22 @@ class MapTileProvider {
|
|||||||
keyParam: 'api_key',
|
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 —
|
/// ── One thing to know before this ships ──
|
||||||
/// present for local development and as a last-resort fallback only.
|
///
|
||||||
|
/// 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(
|
static const osm = MapTileProvider(
|
||||||
id: 'osm',
|
id: 'osm',
|
||||||
name: 'OpenStreetMap standard tiles',
|
name: 'OpenStreetMap standard tiles',
|
||||||
@@ -237,15 +249,17 @@ class DmMapConfig {
|
|||||||
);
|
);
|
||||||
|
|
||||||
factory DmMapConfig.fromEnvironment() {
|
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;
|
String? warning;
|
||||||
|
|
||||||
MapTileProvider provider;
|
MapTileProvider provider;
|
||||||
if (id == 'custom') {
|
if (id == 'custom') {
|
||||||
if (_url.isEmpty) {
|
if (_url.isEmpty) {
|
||||||
provider = MapTileProvider.carto;
|
provider = MapTileProvider.osm;
|
||||||
warning =
|
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 {
|
} else {
|
||||||
provider = MapTileProvider(
|
provider = MapTileProvider(
|
||||||
id: 'custom',
|
id: 'custom',
|
||||||
@@ -268,9 +282,9 @@ class DmMapConfig {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
provider = MapTileProvider.presets[id] ?? MapTileProvider.carto;
|
provider = MapTileProvider.presets[id] ?? MapTileProvider.osm;
|
||||||
if (!MapTileProvider.presets.containsKey(id)) {
|
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
|
/// 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
|
/// 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.
|
/// 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 {
|
class OtpChallenge {
|
||||||
const OtpChallenge({
|
const OtpChallenge({
|
||||||
this.codeLength = 4,
|
this.codeLength = 4,
|
||||||
@@ -976,7 +1044,7 @@ class DestinationGroup {
|
|||||||
return {
|
return {
|
||||||
...destination.toJson(),
|
...destination.toJson(),
|
||||||
'packageCount': packageCount,
|
'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.
|
// `details: {}` is a key that says nothing.
|
||||||
if (detail.isNotEmpty) 'details': detail,
|
if (detail.isNotEmpty) 'details': detail,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,7 +81,17 @@ class AppState extends ChangeNotifier {
|
|||||||
void signIn(Customer c) {
|
void signIn(Customer c) {
|
||||||
customer = c;
|
customer = c;
|
||||||
notifyListeners();
|
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.
|
/// Looks for a persisted session at launch.
|
||||||
@@ -340,6 +350,33 @@ class AppState extends ChangeNotifier {
|
|||||||
return verified;
|
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
|
/// 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.
|
/// for a street name answers with the one in their city first.
|
||||||
Future<List<Place>> searchPlaces(String query) {
|
Future<List<Place>> searchPlaces(String query) {
|
||||||
@@ -426,7 +463,7 @@ class AppState extends ChangeNotifier {
|
|||||||
///
|
///
|
||||||
/// ── Two ways to book the same parcel ──
|
/// ── 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
|
/// 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
|
/// filled in by the Miler standing at the address with the parcel in their
|
||||||
/// hand, which is the only moment anybody actually knows them.
|
/// hand, which is the only moment anybody actually knows them.
|
||||||
|
|||||||
@@ -7,11 +7,27 @@ import '../../widgets/feedback.dart';
|
|||||||
import '../../widgets/inputs.dart';
|
import '../../widgets/inputs.dart';
|
||||||
import 'auth_scaffold.dart';
|
import 'auth_scaffold.dart';
|
||||||
import 'otp_screen.dart';
|
import 'otp_screen.dart';
|
||||||
import 'signup_screen.dart';
|
import 'pin_screen.dart';
|
||||||
|
|
||||||
enum LoginMode { phone, email }
|
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 {
|
class LoginScreen extends StatefulWidget {
|
||||||
const LoginScreen({super.key});
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
@@ -46,6 +62,33 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
final raw = _identifier.text.trim();
|
final raw = _identifier.text.trim();
|
||||||
setState(() => _sending = true);
|
setState(() => _sending = true);
|
||||||
final target = _isPhone ? '+91 $raw' : raw;
|
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 {
|
try {
|
||||||
final challenge = await AppScope.read(context).sendOtp(target);
|
final challenge = await AppScope.read(context).sendOtp(target);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -63,8 +106,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
if (!challenge.sent) {
|
if (!challenge.sent) {
|
||||||
DmToast.show(
|
DmToast.show(
|
||||||
context,
|
context,
|
||||||
'We could not send a code to that ${_isPhone ? 'number' : 'address'}. '
|
'We could not send a code to that address. Try again in a moment.',
|
||||||
'Try again in a moment.',
|
|
||||||
);
|
);
|
||||||
return;
|
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 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
|
// the footer the moment the keyboard came up — the one control the screen
|
||||||
// exists for, hidden exactly when it is needed.
|
// exists for, hidden exactly when it is needed.
|
||||||
footer: Column(
|
// ── No "Create account" link ──
|
||||||
mainAxisSize: MainAxisSize.min,
|
//
|
||||||
children: [
|
// It pushed a separate sign-up screen that asked for a name and then
|
||||||
AuthSwitchLink(
|
// sent an SMS code. A new number is now recognised by
|
||||||
question: 'New to Doormile?',
|
// `POST /auth/login` and [PinScreen] asks for the name and the PIN in
|
||||||
action: 'Create account',
|
// one step, so a second entrance would ask the same questions twice and
|
||||||
onTap: () => Navigator.of(context).push(
|
// send a code that cannot arrive.
|
||||||
MaterialPageRoute<void>(builder: (_) => const SignUpScreen()),
|
footer: const AuthLegal(),
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
const AuthLegal(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
children: [
|
children: [
|
||||||
// No label above it. The heading already said what to type, and a
|
// No label above it. The heading already said what to type, and a
|
||||||
// field captioned "Phone number" under a heading reading "Enter your
|
// field captioned "Phone number" under a heading reading "Enter your
|
||||||
@@ -163,7 +199,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
DmButton(
|
DmButton(
|
||||||
label: 'Continue',
|
label: 'Continue',
|
||||||
busy: _sending,
|
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,
|
onPressed: _valid ? _continue : null,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,6 @@ class _OtpScreenState extends State<OtpScreen> {
|
|||||||
try {
|
try {
|
||||||
await app.verifyOtp(widget.identifier, _digits, name: widget.name);
|
await app.verifyOtp(widget.identifier, _digits, name: widget.name);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
app.detectPickupLocation();
|
|
||||||
unawaited(HapticFeedback.mediumImpact());
|
unawaited(HapticFeedback.mediumImpact());
|
||||||
await navigator.pushAndRemoveUntil(
|
await navigator.pushAndRemoveUntil(
|
||||||
MaterialPageRoute<void>(builder: (_) => const ShellScreen()),
|
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 ──
|
// ── Said here, not discovered later ──
|
||||||
//
|
//
|
||||||
// The rider's call button dials the account, and
|
// The rider's call button dials the account, and
|
||||||
// nothing this app sends can change that. Better
|
// nothing this app sends can change that.
|
||||||
// to say so beside the field than to let somebody
|
//
|
||||||
// hand their parcel to a neighbour believing the
|
// This used to say "we pass this to your Miler as
|
||||||
// Miler has the neighbour's number.
|
// 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(
|
Text(
|
||||||
'We pass this to your Miler as a note. Their '
|
'We record this on your booking. Your Miler '
|
||||||
'call button still dials your own number.',
|
'still calls your own number, so tell them '
|
||||||
|
'yourself if the handover matters.',
|
||||||
style: DmText.small.copyWith(
|
style: DmText.small.copyWith(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
height: 1.45,
|
height: 1.45,
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ class _SendScreenState extends State<SendScreen> {
|
|||||||
),
|
),
|
||||||
// ── Only the long way asks for this ──
|
// ── 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
|
// 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
|
// 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
|
// here, it is outstanding until it is answered, and the button waits
|
||||||
@@ -591,13 +591,16 @@ class _ContactCard extends StatelessWidget {
|
|||||||
if (handover.isNotEmpty) ...[
|
if (handover.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
|
// Recorded, not relayed. `remarks` reaches the
|
||||||
|
// console and stops: the rider app reads a `notes`
|
||||||
|
// field the backend does not send.
|
||||||
who.isEmpty
|
who.isEmpty
|
||||||
? '$handover is handing it over. We will pass '
|
? '$handover is handing it over. Noted on your '
|
||||||
'this on — your Miler still calls the '
|
'booking — your Miler still calls the '
|
||||||
'number above.'
|
'number above.'
|
||||||
: '$who ($handover) is handing it over. We will '
|
: '$who ($handover) is handing it over. Noted on '
|
||||||
'pass this on — your Miler still calls the '
|
'your booking — your Miler still calls '
|
||||||
'number above.',
|
'the number above.',
|
||||||
style: DmText.small.copyWith(
|
style: DmText.small.copyWith(
|
||||||
fontSize: 12.5,
|
fontSize: 12.5,
|
||||||
height: 1.45,
|
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
|
// 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
|
// 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
|
// three addresses, and "for all parcels" is the whole point of choosing
|
||||||
// this path over One Touch.
|
// this path over BOOK NOW.
|
||||||
if (detailed) {
|
if (detailed) {
|
||||||
for (final group in app.draftDestinations) {
|
for (final group in app.draftDestinations) {
|
||||||
if (!mounted) return;
|
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 ──
|
/// ── 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
|
/// writes down the door when they get there. It is a sphere because it is one
|
||||||
/// gesture, and a gesture cannot be a form.
|
/// gesture, and a gesture cannot be a form.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
||||||
import '../../data/models.dart';
|
import '../../data/models.dart';
|
||||||
import '../../state/app_scope.dart';
|
import '../../state/app_scope.dart';
|
||||||
import '../tokens.dart';
|
import '../tokens.dart';
|
||||||
|
import '../widgets/pieces.dart';
|
||||||
import '../widgets/inputs.dart';
|
import '../widgets/inputs.dart';
|
||||||
import '../widgets/states.dart';
|
import '../widgets/states.dart';
|
||||||
import 'order_row.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
|
/// 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
|
/// 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.
|
/// 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
|
/// The chips were three containers, one filled solid black, sitting above a
|
||||||
/// above a list of cards — the loudest object on the screen was the filter.
|
/// list of cards — the loudest object on the screen was the filter. Plain text
|
||||||
/// Plain text tabs with a rule fixed that and left the row looking like a
|
/// tabs with a rule fixed that and left the row looking like a heading rather
|
||||||
/// heading rather than a control.
|
/// 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
|
/// This was then a *second* implementation of that shape: its own `_Tab`, a
|
||||||
/// control and still quiet: white lifted out of a warm bed, ink type, and the
|
/// pill radius, 3pt of padding and the count folded into the label's text —
|
||||||
/// count beside the label because that is the reason anyone looks at the two
|
/// beside the pickup window's day switcher, which is [DmChoiceChip] in a
|
||||||
/// tabs they are not on.
|
/// 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 {
|
class _Filter extends StatelessWidget {
|
||||||
const _Filter({
|
const _Filter({
|
||||||
required this.labels,
|
required this.labels,
|
||||||
@@ -213,88 +215,28 @@ class _Filter extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(3),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: DmColors.groove,
|
color: DmColors.groove,
|
||||||
borderRadius: DmRadius.all(DmRadius.pill),
|
borderRadius: DmRadius.all(DmRadius.md),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < labels.length; i++)
|
for (var i = 0; i < labels.length; i++) ...[
|
||||||
Flexible(
|
if (i > 0) const SizedBox(width: 4),
|
||||||
child: _Tab(
|
DmChoiceChip(
|
||||||
label: labels[i],
|
label: labels[i],
|
||||||
count: counts[i],
|
// Zero is not a figure worth a bubble — an empty tab says so by
|
||||||
selected: i == index,
|
// being empty when you open it.
|
||||||
onTap: () => onChanged(i),
|
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
|
/// 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
|
/// 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:
|
/// Miler that step, so the form has to be quick enough to be worth choosing:
|
||||||
/// one surface, five fields, one button.
|
/// one surface, five fields, one button.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import '../../widgets/feedback.dart';
|
|||||||
import 'destination_sheet.dart';
|
import 'destination_sheet.dart';
|
||||||
import 'window_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 ──
|
/// ── What this replaced ──
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -75,18 +75,34 @@ class DmColors {
|
|||||||
static const ink4 = Color(0xFFB4AEAA);
|
static const ink4 = Color(0xFFB4AEAA);
|
||||||
|
|
||||||
// ── Surfaces ─────────────────────────────────────────────────────────────
|
// ── Surfaces ─────────────────────────────────────────────────────────────
|
||||||
/// The page canvas.
|
/// The page canvas. White.
|
||||||
///
|
///
|
||||||
/// Deepened on 2026-09-23. At `#F7F6F4` a white card on it was a 3% step,
|
/// ── This has been both, and the trade is real ──
|
||||||
/// so the 1px border was doing all the separating and every screen read as
|
///
|
||||||
/// a list of outlined boxes. A canvas the cards can sit *on* is what lets
|
/// It was `#F7F6F4`, then deepened to `#F1F0EC` on 2026-09-23 so a white
|
||||||
/// the edge go quiet.
|
/// card would sit *on* something and the 1px edge could go quiet. That
|
||||||
static const canvas = Color(0xFFF1F0EC);
|
/// worked, and it also made every screen read as slightly dull — which is
|
||||||
|
/// the note that brought it here.
|
||||||
|
///
|
||||||
|
/// You cannot have a white page and borderless cards: one of them has to do
|
||||||
|
/// the separating. On white, [DmCard] is held by its border and
|
||||||
|
/// [DmShadow.card] rather than by contrast, which is the iOS and Revolut
|
||||||
|
/// shape rather than the Airbnb one. That is the choice, made deliberately.
|
||||||
|
///
|
||||||
|
/// Everything that needs to read as *recessed* — filled inputs, the
|
||||||
|
/// segmented groove, a sunken card — moved to [surfaceAlt] and [groove],
|
||||||
|
/// which are the only tokens still darker than the page.
|
||||||
|
static const canvas = Color(0xFFFFFFFF);
|
||||||
static const surface = Color(0xFFFFFFFF);
|
static const surface = Color(0xFFFFFFFF);
|
||||||
|
|
||||||
/// Filled inputs, quiet chips, tiles, skeleton bases. Also the "sunken"
|
/// Filled inputs, quiet chips, tiles, skeleton bases, the "sunken" card and
|
||||||
/// treatment for a disabled primary action.
|
/// the sunken treatment for a disabled primary action.
|
||||||
static const surfaceAlt = Color(0xFFF1EFEC);
|
///
|
||||||
|
/// With a white canvas this is the *only* thing that makes a surface read as
|
||||||
|
/// recessed, so it carries more weight than it used to. It was within a
|
||||||
|
/// point of the old canvas, which is why the groove below had to exist at
|
||||||
|
/// all; on white it does its own job again.
|
||||||
|
static const surfaceAlt = Color(0xFFF4F2EF);
|
||||||
|
|
||||||
/// The bed a segmented control's chosen segment is raised out of.
|
/// The bed a segmented control's chosen segment is raised out of.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -60,7 +60,14 @@ class DmBookOrb extends StatefulWidget {
|
|||||||
const DmBookOrb({
|
const DmBookOrb({
|
||||||
super.key,
|
super.key,
|
||||||
required this.onTap,
|
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.caption,
|
||||||
this.held = false,
|
this.held = false,
|
||||||
this.field = maxField,
|
this.field = maxField,
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ class DmCard extends StatelessWidget {
|
|||||||
DmCardTone.plain => DmColors.surface,
|
DmCardTone.plain => DmColors.surface,
|
||||||
DmCardTone.fill => DmColors.brand,
|
DmCardTone.fill => DmColors.brand,
|
||||||
DmCardTone.wash => DmColors.brandSoft,
|
DmCardTone.wash => DmColors.brandSoft,
|
||||||
DmCardTone.sunken => DmColors.canvas,
|
// Not the canvas any more: the page is white, and a card filled with
|
||||||
|
// the page colour is not sunken, it is invisible.
|
||||||
|
DmCardTone.sunken => DmColors.surfaceAlt,
|
||||||
};
|
};
|
||||||
|
|
||||||
Color get _edge => switch (tone) {
|
Color get _edge => switch (tone) {
|
||||||
@@ -106,15 +108,25 @@ class DmCard extends StatelessWidget {
|
|||||||
boxShadow: _lifts ? (raised ? DmShadow.cardRaised : DmShadow.card) : null,
|
boxShadow: _lifts ? (raised ? DmShadow.cardRaised : DmShadow.card) : null,
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
// A tone that is not white keeps its edge: a crimson or washed card is
|
// ── Every card keeps its edge now ──
|
||||||
// already separated from the canvas by its own colour, and a shadow
|
//
|
||||||
// under a saturated fill reads as grime rather than as lift.
|
// A white card used to have none: the warm canvas underneath was doing
|
||||||
foregroundDecoration: _lifts
|
// the separating, and a border on top of that contrast read as an
|
||||||
? null
|
// outlined box rather than a lifted surface.
|
||||||
: BoxDecoration(
|
//
|
||||||
borderRadius: radius,
|
// The page is white. A white card on it is invisible — measured, not
|
||||||
border: Border.all(color: _edge),
|
// guessed: a scan across a card boundary on the Orders golden was
|
||||||
),
|
// #FFFFFF from end to end, with the shadow contributing nothing the
|
||||||
|
// eye could find. One of the two has to do the separating, and with
|
||||||
|
// the page white it has to be the edge.
|
||||||
|
//
|
||||||
|
// The lift stays underneath it. Border plus a soft shadow is the iOS
|
||||||
|
// and Revolut shape; it is not the borderless Airbnb one, and that is
|
||||||
|
// the cost of a white page.
|
||||||
|
foregroundDecoration: BoxDecoration(
|
||||||
|
borderRadius: radius,
|
||||||
|
border: Border.all(color: _edge),
|
||||||
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: radius,
|
borderRadius: radius,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class DmTextField extends StatefulWidget {
|
|||||||
this.autofocus = false,
|
this.autofocus = false,
|
||||||
this.digitsOnly = false,
|
this.digitsOnly = false,
|
||||||
this.mono = false,
|
this.mono = false,
|
||||||
|
this.obscure = false,
|
||||||
this.onClear,
|
this.onClear,
|
||||||
this.floating = false,
|
this.floating = false,
|
||||||
});
|
});
|
||||||
@@ -44,6 +45,11 @@ class DmTextField extends StatefulWidget {
|
|||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final String? hint;
|
final String? hint;
|
||||||
final bool optional;
|
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 TextInputType? keyboardType;
|
||||||
final int? maxLength;
|
final int? maxLength;
|
||||||
final int maxLines;
|
final int maxLines;
|
||||||
@@ -187,6 +193,11 @@ class _DmTextFieldState extends State<DmTextField> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
focusNode: _node,
|
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,
|
keyboardType: widget.keyboardType,
|
||||||
maxLines: widget.maxLines,
|
maxLines: widget.maxLines,
|
||||||
autofocus: widget.autofocus,
|
autofocus: widget.autofocus,
|
||||||
|
|||||||
@@ -131,7 +131,11 @@ class DmChoiceChip extends StatelessWidget {
|
|||||||
duration: DmMotion.base,
|
duration: DmMotion.base,
|
||||||
curve: DmMotion.ease,
|
curve: DmMotion.ease,
|
||||||
constraints: const BoxConstraints(minHeight: 40),
|
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,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: selected ? DmColors.surface : Colors.transparent,
|
color: selected ? DmColors.surface : Colors.transparent,
|
||||||
@@ -155,7 +159,7 @@ class DmChoiceChip extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (count != null) ...[
|
if (count != null) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 5),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ class FakeApi extends DoormileApi {
|
|||||||
/// can make an earlier request land after a later one.
|
/// can make an earlier request land after a later one.
|
||||||
final Map<double, (Duration, Place)> geocodes = {};
|
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
|
@override
|
||||||
Future<BookingLimits> getBookingLimits({Place? pickup}) async {
|
Future<BookingLimits> getBookingLimits({Place? pickup}) async {
|
||||||
if (limitsError != null) throw limitsError!;
|
if (limitsError != null) throw limitsError!;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:doormile_cx/ui/widgets/inputs.dart';
|
||||||
import 'package:doormile_cx/data/doormile_api.dart';
|
import 'package:doormile_cx/data/doormile_api.dart';
|
||||||
import 'package:doormile_cx/data/location_service.dart';
|
import 'package:doormile_cx/data/location_service.dart';
|
||||||
import 'package:doormile_cx/data/models.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 tester.tap(find.text('Continue'));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
// One field, not four boxes, and it verifies as soon as the fourth digit
|
// ── A PIN now, not a posted code ──
|
||||||
// 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
|
// The SMS gateway is off: `otp/request` still answers `sent: true` and
|
||||||
// what typing at any normal speed does.
|
// writes the code to the server log, so the old four-box screen could never
|
||||||
await tester.enterText(find.byType(TextField).first, '1111');
|
// 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);
|
await settle(tester);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +92,7 @@ Future<void> serverAdvance(
|
|||||||
/// The sheet is left open: tests that are about the sheet itself go no
|
/// The sheet is left open: tests that are about the sheet itself go no
|
||||||
/// further.
|
/// further.
|
||||||
Future<void> openSend(WidgetTester tester) async {
|
Future<void> openSend(WidgetTester tester) async {
|
||||||
await tester.tap(find.text('ONE TOUCH'));
|
await tester.tap(find.text('BOOK NOW'));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,38 +178,85 @@ void main() {
|
|||||||
|
|
||||||
tearDown(() => DoormileApi.overrideInstance(null));
|
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 {
|
(tester) async {
|
||||||
// The regression this locks: the code used to be four `TextField`s that
|
// ── The regression this locks ──
|
||||||
// passed focus along, and a keystroke arriving mid-transition was dropped.
|
//
|
||||||
// The customer typed four digits, three arrived, and the screen answered
|
// PIN sign-in has three entrances that look identical to a customer: an
|
||||||
// "That code did not match" — blaming them for its own race.
|
// 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.physicalSize = const Size(1230, 9000);
|
||||||
tester.view.devicePixelRatio = 3.0;
|
tester.view.devicePixelRatio = 3.0;
|
||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
await tester.pumpWidget(const DoormileApp());
|
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 settle(tester, 4400);
|
||||||
|
|
||||||
|
// A number the fake backend knows, with a PIN already set.
|
||||||
await tester.enterText(find.byType(TextField).first, '9876543210');
|
await tester.enterText(find.byType(TextField).first, '9876543210');
|
||||||
await settle(tester, 200);
|
await settle(tester, 200);
|
||||||
await tester.tap(find.text('Continue'));
|
await tester.tap(find.text('Continue'));
|
||||||
await settle(tester);
|
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(find.byType(TextField), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.widget<DmTextField>(find.byType(DmTextField)).obscure,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
|
||||||
final field = find.byType(TextField).first;
|
// A wrong PIN says so without saying which of the two was wrong.
|
||||||
for (final sofar in ['1', '12', '123', '1234']) {
|
await tester.enterText(find.byType(TextField).first, '9999');
|
||||||
await tester.enterText(field, sofar);
|
await settle(tester, 200);
|
||||||
await tester.pump();
|
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);
|
await settle(tester);
|
||||||
|
|
||||||
// All four digits arrived, so it verified and moved on.
|
// Name, PIN, confirm — the sign-up screen's questions, without a second
|
||||||
expect(find.text('ONE TOUCH'), findsOneWidget);
|
// 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);
|
await drainToasts(tester);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,7 +265,7 @@ void main() {
|
|||||||
|
|
||||||
// Home greets by name, carries the sphere, and ends on the live card.
|
// Home greets by name, carries the sphere, and ends on the live card.
|
||||||
expect(find.text('Hello, Joe'), findsOneWidget);
|
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
|
// The seeded active booking surfaces on Home. Its state is the uppercase
|
||||||
// micro-label beside the destination, in the chip vocabulary — "Assigned",
|
// micro-label beside the destination, in the chip vocabulary — "Assigned",
|
||||||
// not the rail's "Miler assigned".
|
// not the rail's "Miler assigned".
|
||||||
@@ -819,7 +872,18 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows the network error state with retry', (tester) async {
|
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;
|
api.flags.networkError = true;
|
||||||
|
|
||||||
await openSend(tester);
|
await openSend(tester);
|
||||||
@@ -870,7 +934,7 @@ void main() {
|
|||||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
|
||||||
await signIn(tester); // login + OTP
|
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 openSend(tester); // the whole booking, one screen
|
||||||
await pickCity(tester, 'Chennai');
|
await pickCity(tester, 'Chennai');
|
||||||
@@ -895,7 +959,8 @@ void main() {
|
|||||||
await drainToasts(tester);
|
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;
|
tester.platformDispatcher.textScaleFactorTestValue = 1.8;
|
||||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
|
||||||
@@ -904,11 +969,17 @@ void main() {
|
|||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
await tester.pumpWidget(const DoormileApp());
|
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 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);
|
await settle(tester);
|
||||||
|
|
||||||
// Building it is the assertion: any overflow in the hero or the form
|
// 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 tester.tap(find.bySemanticsLabel('Orders'));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
// Each tab carries its own count — the figure is the rows that tab will
|
// ── Label and count are separate widgets now ──
|
||||||
// actually show, which for a collected multi-destination pickup is one
|
//
|
||||||
// per destination. The count shares a paragraph with the label, so it is
|
// The filter used to fold the figure into the label's own paragraph
|
||||||
// a span rather than a Text of its own.
|
// ("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(
|
expect(
|
||||||
find.textContaining(
|
find.text('${state.entriesOf(BookingStatus.active).length}'),
|
||||||
'Active (${state.entriesOf(BookingStatus.active).length})',
|
findsWidgets,
|
||||||
),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// The tab row scrolls — three labels plus their counts do not fit a 390pt
|
// 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 _settle(tester, 4400);
|
||||||
await _shot(tester, '01-sign-in');
|
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 _settle(tester);
|
||||||
await _shot(tester, '01b-sign-up');
|
await _shot(tester, '01b-create-account');
|
||||||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||||||
await _settle(tester);
|
await _settle(tester);
|
||||||
|
|
||||||
@@ -163,9 +172,13 @@ void main() {
|
|||||||
await _settle(tester, 200);
|
await _settle(tester, 200);
|
||||||
await tester.tap(find.text('Continue'));
|
await tester.tap(find.text('Continue'));
|
||||||
await _settle(tester);
|
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 _settle(tester);
|
||||||
await _shot(tester, '03-home');
|
await _shot(tester, '03-home');
|
||||||
|
|
||||||
@@ -217,7 +230,7 @@ void main() {
|
|||||||
// districts inside a state, then a separate window sheet. There are no
|
// 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
|
// states to open now — the list is flat and the state is a heading — and
|
||||||
// the window is the same sheet's second step.
|
// 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 _settle(tester);
|
||||||
await _shot(tester, '07-pickup-where');
|
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
|
/// The login screen must not walk a customer to the code screen when no code
|
||||||
/// was sent.
|
/// 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
|
/// `sent: false` is the server saying it accepted the request and delivered
|
||||||
/// nothing — the SMS gateway or the mail relay refused it. Advancing anyway
|
/// 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,
|
/// 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.
|
// settle in the suite.
|
||||||
await settle(tester, 4400);
|
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 settle(tester, 200);
|
||||||
await tester.tap(find.text('Continue'));
|
await tester.tap(find.text('Continue'));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
@@ -74,7 +85,7 @@ void main() {
|
|||||||
// instruction, a field and a button, so the heading is now the
|
// instruction, a field and a button, so the heading is now the
|
||||||
// instruction. What this asserts is unchanged: the customer is still on
|
// instruction. What this asserts is unchanged: the customer is still on
|
||||||
// the sign-in screen and can try again.
|
// the sign-in screen and can try again.
|
||||||
find.text('Enter your mobile number'),
|
find.text('Enter your email address'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
reason: 'the customer stays where they can try again',
|
reason: 'the customer stays where they can try again',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,13 +53,19 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('resolved configuration', () {
|
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();
|
final config = DmMapConfig.fromEnvironment();
|
||||||
expect(config.provider.id, 'carto');
|
expect(config.provider.id, 'osm');
|
||||||
expect(config.warning, isNull);
|
expect(config.warning, isNull);
|
||||||
expect(config.userAgent, contains('Doormile/'));
|
expect(config.userAgent, contains('Doormile/'));
|
||||||
expect(config.userAgent, contains(DmMapConfig.packageName));
|
expect(config.userAgent, contains(DmMapConfig.packageName));
|
||||||
expect(config.attribution, contains('CARTO'));
|
expect(config.attribution, contains('OpenStreetMap'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('is swappable without touching a screen', () {
|
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
|
/// 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
|
/// later. Nothing was wrong with either height — the sheet was simply as tall
|
||||||
/// as whatever [DmAsyncList] happened to be rendering, and that changes twice
|
/// as whatever [DmAsyncList] happened to be rendering, and that changes twice
|
||||||
/// before the customer has done anything: four skeleton rows at 302pt, then
|
/// before the customer has done anything: four skeleton rows at 302pt, then
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 111 KiB |
BIN
test/snapshots/01b-create-account.png
Normal file
|
After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 152 KiB |
BIN
test/snapshots/02-pin.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 418 KiB After Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 304 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 252 KiB |
@@ -12,8 +12,8 @@ pairs = [
|
|||||||
('00b-splash-invert.png', '00b-splash-invert.png'),
|
('00b-splash-invert.png', '00b-splash-invert.png'),
|
||||||
('00c-splash-mark.png', '00c-splash-mark.png'),
|
('00c-splash-mark.png', '00c-splash-mark.png'),
|
||||||
('01-sign-in.png', '01-sign-in.png'),
|
('01-sign-in.png', '01-sign-in.png'),
|
||||||
('01b-sign-up.png', '02-sign-up.png'),
|
('01b-create-account.png', '02-create-account.png'),
|
||||||
('02-verify.png', '03-verify-code.png'),
|
('02-pin.png', '03-enter-pin.png'),
|
||||||
('03-home.png', '04-home.png'),
|
('03-home.png', '04-home.png'),
|
||||||
('03b-pickup-search.png', '05-pickup-search.png'),
|
('03b-pickup-search.png', '05-pickup-search.png'),
|
||||||
('04-orders.png', '06-orders-active.png'),
|
('04-orders.png', '06-orders-active.png'),
|
||||||
|
|||||||