── The full-address path was reaching the Miler empty ──
`DestinationGroup.toBookingJson` spread its details FLAT across the
destination. The contract nests them under `details{}`, and a destination
carrying keys the server does not recognise is accepted without a word — so
every building number, street, landmark, recipient name, recipient phone and
pin a customer typed was written, answered 201, and thrown away. The Miler
arrived with a district.
Four more on the same call. The destination pin spelled `latitude`/`longitude`
— the same spelling that answered 422 unserviceable for months on the pickup
before it was fixed there and missed here. A PATCH that sent `null` to clear a
field, with a comment saying so, when the server writes only non-nil values, so
a landmark could be added and never removed. Per-destination `instructions`
folded into the visit's one `remarks` line on the belief the contract had no
per-destination note; it has one. And `contactName`/`contactPhone` on the
pickup object, which the create contract has no room for and drops.
The fix ships unverified, deliberately. If `details{}` is also the wrong shape
the fields drop exactly as they do today — it cannot be worse, and holding it
costs every full-address booking in the meantime. docs/BACKEND_CHANGES.md asks
for the confirmation; tool/verify_booking.sh runs it in one command.
── Who the Miler rings ──
One number reaches the rider and it is the account's: `GET /miler/bookings`
returns a single `customerphone`, verified against production and written down
in the rider app's own stop_contact.dart. So "Someone else is handing it over?"
was collecting a number that reached nobody.
Review now shows the number that will actually be dialled, and the handover
person travels in `remarks` with a name, labelled for whoever reads it. Both
screens say plainly that the rider's call button still dials the account —
better than letting somebody hand their parcel to a neighbour believing
otherwise.
── Account's rows led nowhere ──
Two had no `onTap` at all — a chevron pointing at a page that did not exist —
and three answered with a toast. Five rows making a promise, one keeping it.
Notifications, Payment, Help and About are real screens now, written to one
rule: say only what is true of this app today. There is no notification
endpoint, no stored payment instrument and no push SDK wired in, so none of
them pretends to manage any of that. Support shows no contact block at all
rather than a number that rings nowhere — AppConfig carries the fields empty
until somebody fills them in.
── ONE TOUCH is one sheet ──
It was two in sequence with a dismissal between them, and the destination step
made you open a state to see any city — two levels of navigation for something
its own search already flattened. One flat list headed by state, which is also
the answer to "where do you deliver?", and one surface that changes its
question instead of closing so another can open.
Home says the reach in a line, and it needed two fixes to appear at all:
`cachedCities` walked closed states looking for districts that are only fetched
for open ones, and `loadCities` filled two caches while notifying nobody.
── Sending a second parcel ──
`maxDestinations` is 1 in production, so two parcels for two places means
booking twice — and that cost the whole flow twice, re-answering a door the
customer had not moved from. `startBookingFrom` carries the door, carries the
destination only when asked, and never carries the window: a slot fills up, and
a second booking pinned to one that is now full is refused at confirm with
nothing the customer can act on.
Review also says why there is no "add another destination", so a cap reads as a
limit rather than a missing button.
── Bundle ──
pubspec named its images one by one. Declaring `assets/images/` as a folder
shipped a 974 KB launcher-icon master to every customer for a file no code
opens.
169 lines
6.6 KiB
Dart
169 lines
6.6 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:doormile_cx/data/dev_doormile_api.dart';
|
|
import 'package:doormile_cx/data/doormile_api.dart';
|
|
import 'package:doormile_cx/data/models.dart';
|
|
import 'package:doormile_cx/state/app_scope.dart';
|
|
import 'package:doormile_cx/state/app_state.dart';
|
|
import 'package:doormile_cx/ui/screens/account_screen.dart';
|
|
import 'package:doormile_cx/ui/screens/settings/about_screen.dart';
|
|
import 'package:doormile_cx/ui/screens/settings/notifications_screen.dart';
|
|
import 'package:doormile_cx/ui/screens/settings/payment_screen.dart';
|
|
import 'package:doormile_cx/ui/screens/settings/support_screen.dart';
|
|
import 'package:doormile_cx/ui/widgets/inputs.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// EVERY ROW ON ACCOUNT OPENS SOMETHING
|
|
///
|
|
/// Two of these rows had no `onTap` at all and three answered with a toast.
|
|
/// All five drew a chevron, which is the affordance for "this opens a page" —
|
|
/// so the screen was making a promise in five places and keeping it in one.
|
|
///
|
|
/// A tap that silently does nothing is the worst version of this, because the
|
|
/// customer cannot tell it from a page that is slow: they press it again.
|
|
///
|
|
/// The check is deliberately shallow and broad. It does not care what is on
|
|
/// each page; it cares that the tap goes somewhere, for every row, which is
|
|
/// the thing that was wrong and the thing that quietly comes back when a row
|
|
/// is added in a hurry.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
void main() {
|
|
late AppState app;
|
|
|
|
setUp(() async {
|
|
DoormileApi.overrideInstance(DevDoormileApi());
|
|
app = AppState()
|
|
// Straight onto the state rather than through the sign-in screens: this
|
|
// is about what Account's rows do, and walking the OTP flow to find out
|
|
// would make every one of these tests fail for reasons in another file.
|
|
..customer = const Customer(
|
|
id: 'c1',
|
|
name: 'Joe Oommen',
|
|
phone: '+91 98765 43210',
|
|
email: 'joe@example.com',
|
|
);
|
|
await app.refreshOrders();
|
|
});
|
|
|
|
tearDown(() => DoormileApi.overrideInstance(null));
|
|
|
|
Future<void> pumpAccount(WidgetTester tester) async {
|
|
// A tall surface so the whole list is laid out. The default 800x600 window
|
|
// leaves the About group below the fold, and `tap` on an off-screen widget
|
|
// warns and misses rather than failing — which reads as "the row does not
|
|
// work" and is the same symptom this file exists to catch.
|
|
tester.view.physicalSize = const Size(1230, 9000);
|
|
tester.view.devicePixelRatio = 3.0;
|
|
addTearDown(tester.view.reset);
|
|
|
|
await tester.pumpWidget(
|
|
AppScope(
|
|
state: app,
|
|
child: const MaterialApp(home: AccountScreen()),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
/// Taps a row by its label and lets whatever it opens settle.
|
|
Future<void> tapRow(WidgetTester tester, String label) async {
|
|
final row = find.ancestor(
|
|
of: find.text(label),
|
|
matching: find.byType(DmRow),
|
|
);
|
|
expect(row, findsOneWidget, reason: 'no row labelled "$label"');
|
|
await tester.tap(row);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
group('the rows that led nowhere', () {
|
|
testWidgets('Notifications opens its page', (tester) async {
|
|
await pumpAccount(tester);
|
|
await tapRow(tester, 'Notifications');
|
|
expect(find.byType(NotificationsScreen), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Payment methods opens its page', (tester) async {
|
|
await pumpAccount(tester);
|
|
await tapRow(tester, 'Payment methods');
|
|
expect(find.byType(PaymentScreen), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Help and support opens its page', (tester) async {
|
|
await pumpAccount(tester);
|
|
await tapRow(tester, 'Help and support');
|
|
expect(find.byType(SupportScreen), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('both policy rows open About', (tester) async {
|
|
await pumpAccount(tester);
|
|
|
|
await tapRow(tester, 'Terms and policies');
|
|
expect(find.byType(AboutScreen), findsOneWidget);
|
|
// Under its own name, not under the other row's.
|
|
expect(find.text('Terms and policies'), findsWidgets);
|
|
|
|
// Not `pageBack()`: that looks for a Material or Cupertino back button
|
|
// and this app's bar carries its own `DmBackButton`.
|
|
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
|
|
await tester.pumpAndSettle();
|
|
|
|
await tapRow(tester, 'About Doormile');
|
|
expect(find.byType(AboutScreen), findsOneWidget);
|
|
});
|
|
});
|
|
|
|
group('no row draws a chevron it cannot honour', () {
|
|
testWidgets('every chevron row is tappable', (tester) async {
|
|
await pumpAccount(tester);
|
|
|
|
final rows = tester.widgetList<DmRow>(find.byType(DmRow)).toList();
|
|
expect(rows, isNotEmpty);
|
|
|
|
final broken = [
|
|
for (final row in rows)
|
|
if (row.showChevron && row.onTap == null) row.label,
|
|
];
|
|
expect(
|
|
broken,
|
|
isEmpty,
|
|
reason: 'these rows point at a page and swallow the tap: $broken',
|
|
);
|
|
});
|
|
});
|
|
|
|
group('the pages say only what is true', () {
|
|
testWidgets('Notifications does not promise push', (tester) async {
|
|
await pumpAccount(tester);
|
|
await tapRow(tester, 'Notifications');
|
|
|
|
// No push SDK is wired in — `AppState.registerPushToken` has nothing to
|
|
// hand over — so the screen states that rather than offering a switch.
|
|
expect(find.textContaining('not switched on yet'), findsOneWidget);
|
|
expect(find.byType(Switch), findsNothing);
|
|
});
|
|
|
|
testWidgets('Payment offers nothing to add or save', (tester) async {
|
|
await pumpAccount(tester);
|
|
await tapRow(tester, 'Payment methods');
|
|
|
|
expect(find.textContaining('Nothing is charged'), findsOneWidget);
|
|
expect(find.textContaining('Add card'), findsNothing);
|
|
});
|
|
|
|
testWidgets('Support shows no contact until one is configured', (
|
|
tester,
|
|
) async {
|
|
await pumpAccount(tester);
|
|
await tapRow(tester, 'Help and support');
|
|
|
|
// AppConfig.supportPhone/Email are empty unless passed at build time.
|
|
// A support line that rings nowhere is worse than none, so the block is
|
|
// absent rather than invented.
|
|
expect(find.text('Talk to us'), findsNothing);
|
|
expect(find.text('Copy details'), findsOneWidget);
|
|
});
|
|
});
|
|
}
|