Files
doormile_milderapp/test/stop_verify_review_test.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

275 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/pickups/stop_verify.dart';
/// ── The line this page's own review still belongs to ──
///
/// [StopVerificationPage] ends with a review only where nothing downstream
/// subsumes it. On **logistics** something does: the shipment desk opens
/// straight after, and its Review & Pay screen covers the parcel, the
/// addresses, the fare and the payment on one page — so a review here would be
/// the same facts twice, two screens apart.
///
/// The review is therefore kept for a line that verifies without pricing, and
/// this is that line. Built here rather than shipped as a profile because no
/// signed client needs it yet; the day one does, this is the shape it takes.
/// See `ServiceProfile.capturesShipmentAddresses`.
const ServiceProfile _verifyOnlyLine = ServiceProfile(
line: ServiceLine.parcel,
label: 'Doormile',
jobNoun: 'booking',
jobNounPlural: 'bookings',
completionVerb: 'Picked up',
collectsCash: true,
needsVerification: true,
needsProofPhoto: true,
acceptsPerStop: true,
sourceIsKitchen: false,
handoffAt: HandoffPoint.accepted,
capturesShipmentAddresses: false,
);
/// The review step used to be untestable — "it sits behind a camera gate no
/// widget test can pass" — so the only thing held about it was the pure packing
/// rule in [packReviewFacts]. That left the screen itself unwatched, and it
/// broke: the fact rows were `Row(crossAxisAlignment: stretch)` inside a
/// `SingleChildScrollView`, which asks the row to stretch to an infinite height.
/// Layout threw, the exception propagated up through the leg panel's Column, and
/// the last screen of the flow — the one the rider signs — showed no figures at
/// all.
///
/// The gate is passable: [ImagePickerPlatform.instance] is the seam. Swapping in
/// a fake that returns a real file on disk lets the whole flow run to the review
/// and assert what the rider is actually shown.
class _FakeCamera extends ImagePickerPlatform {
final String path;
_FakeCamera(this.path);
@override
Future<XFile?> getImageFromSource({
required ImageSource source,
ImagePickerOptions options = const ImagePickerOptions(),
}) async => XFile(path);
}
void main() {
setUp(() => ServiceProfile.setActive(_verifyOnlyLine));
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
setUpAll(() async {
final dir = await Directory.systemTemp.createTemp('miler_review');
final png = File('${dir.path}/proof.png')
// A 1×1 PNG: Image.file needs bytes it can decode, not a real photo.
..writeAsBytesSync(const <int>[
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, //
0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137,
0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 250, 207, 192, 240,
31, 0, 5, 5, 2, 0, 141, 88, 175, 91, 0, 0, 0, 0, 73, 69,
78, 68, 174, 66, 96, 130,
]);
ImagePickerPlatform.instance = _FakeCamera(png.path);
});
Future<void> pump(
WidgetTester tester,
Map<String, dynamic> stop, {
double width = 390,
double textScale = 1.0,
}) async {
tester.view.physicalSize = Size(width * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: TextScaler.linear(textScale)),
child: child!,
),
home: StopVerificationPage(pickup: stop),
),
),
);
await tester.pumpAndSettle();
}
/// Taps by visible label, scrolling it into view first — every one of these
/// controls sits below the fold on a phone.
Future<void> tap(WidgetTester tester, String label) async {
final target = find.text(label).first;
await tester.ensureVisible(target);
await tester.pumpAndSettle();
await tester.tap(target);
await tester.pumpAndSettle();
}
Future<void> type(WidgetTester tester, String hint, String value) async {
await tester.enterText(
find.byWidgetPredicate(
(w) => w is TextField && w.decoration?.hintText == hint,
),
value,
);
await tester.pumpAndSettle();
}
Map<String, dynamic> stopOf({
required String type,
int deliveryQty = 0,
int pickupQty = 0,
double cash = 0,
}) => <String, dynamic>{
'orderid': 'T-1',
'type': type,
'pickupcustomer': 'Priya Sharma',
'pickupaddress': '45, Lake View Apartments, Coimbatore',
if (deliveryQty > 0) 'deliveryqty': '$deliveryQty',
if (pickupQty > 0) 'pickupqty': '$pickupQty',
'Quantity': '${pickupQty > 0 ? pickupQty : deliveryQty}',
if (cash > 0) 'collectionamt': '$cash',
};
testWidgets('a pickup stop shows every collected figure on the review', (
tester,
) async {
await pump(tester, stopOf(type: 'pickup', pickupQty: 3, cash: 2450));
await type(tester, 'Total weight in kg', '4.5');
await tap(tester, 'Take photo');
// The pickup code is six boxes over one hidden field, not a hinted one.
await tester.enterText(find.byType(TextField).last, '482156');
await tester.pumpAndSettle();
await tap(tester, 'Review');
// The heading is the screen's own voice, not another section title.
expect(find.text('Check before you confirm'), findsOneWidget);
// Every figure the rider is signing for. Before the layout fix this whole
// block rendered as nothing.
expect(find.text('COLLECTED'), findsOneWidget);
expect(find.text('3 of 3'), findsOneWidget);
expect(find.text('4.5 kg'), findsOneWidget);
expect(find.text('Sealed & intact'), findsOneWidget);
expect(find.text('482156'), findsOneWidget);
// The photo is shown, not asserted as a tick the rider cannot check.
expect(find.byType(Image), findsWidgets);
expect(find.text('Tap to enlarge'), findsOneWidget);
// Money is flagged before the commit, not after it.
expect(
find.text('Collect ₹2450 from the customer on the next screen'),
findsOneWidget,
);
expect(find.text('Confirm pickup'), findsOneWidget);
});
testWidgets('a combined stop reviews both legs separately', (tester) async {
await pump(tester, stopOf(type: 'combined', deliveryQty: 2, pickupQty: 3));
// Hand over.
await tap(tester, 'Parcel 1 of 2');
await tap(tester, 'Parcel 2 of 2');
await tap(tester, 'Security');
await type(tester, "Person's name", 'Ramesh Kumar');
await tester.enterText(find.byType(TextField).last, '482156');
await tester.pumpAndSettle();
await tap(tester, 'Take photo');
await tap(tester, 'Continue to collect');
// Collect, with a damaged seal so the note takes its own wide row.
await tap(tester, 'Damaged');
await type(tester, 'Total weight in kg', '12.75');
await type(
tester,
'e.g. corner of the box is crushed',
'Corner of the second box is crushed',
);
await tap(tester, 'Take photo');
await tap(tester, 'Review');
// Two panels, each with its own facts and its own photo — the pairing is
// the whole reason both shots are taken.
expect(find.text('HANDED OVER'), findsOneWidget);
expect(find.text('2 of 2'), findsOneWidget);
expect(find.text('Ramesh Kumar · Security'), findsOneWidget);
expect(find.text('COLLECTED'), findsOneWidget);
expect(find.text('3 of 3'), findsOneWidget);
expect(find.text('12.75 kg'), findsOneWidget);
expect(find.text('Damaged'), findsOneWidget);
expect(find.text('Corner of the second box is crushed'), findsOneWidget);
expect(find.text('Tap to enlarge'), findsNWidgets(2));
// Both legs are complete, and each says so on itself — on a combined stop
// the page-level verdict does not say which job is short.
expect(find.text('Complete'), findsNWidgets(2));
// No pickup code on a combined stop: the delivery OTP already proved the
// door, so it is never collected and must not be reviewed.
expect(find.text('Pickup code'), findsNothing);
});
testWidgets('tapping a proof photo opens it full frame', (tester) async {
await pump(tester, stopOf(type: 'pickup', pickupQty: 1));
await type(tester, 'Total weight in kg', '4.5');
await tap(tester, 'Take photo');
await tester.enterText(find.byType(TextField).last, '482156');
await tester.pumpAndSettle();
await tap(tester, 'Review');
await tap(tester, 'Tap to enlarge');
expect(find.byType(InteractiveViewer), findsOneWidget);
});
// The review is a grid of fixed-width halves, which is the shape that
// overflows when the system font grows. Measured rather than eyeballed.
for (final scale in const [1.0, 1.3, 1.5, 2.0]) {
testWidgets('the review lays out at ${scale}x on a 360pt phone', (
tester,
) async {
await pump(
tester,
stopOf(type: 'combined', deliveryQty: 2, pickupQty: 3, cash: 2450),
width: 360,
textScale: scale,
);
await tap(tester, 'Parcel 1 of 2');
await tap(tester, 'Parcel 2 of 2');
await tap(tester, 'Security');
await type(tester, "Person's name", 'Ramesh Kumar Subramaniam');
await tester.enterText(find.byType(TextField).last, '482156');
await tester.pumpAndSettle();
await tap(tester, 'Take photo');
await tap(tester, 'Continue to collect');
await tap(tester, 'Damaged');
await type(tester, 'Total weight in kg', '12.75');
await type(
tester,
'e.g. corner of the box is crushed',
'Corner of the second box is crushed on the underside',
);
await tap(tester, 'Take photo');
await tap(tester, 'Review');
expect(find.text('HANDED OVER'), findsOneWidget);
expect(find.text('COLLECTED'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
}