import 'dart:async'; import 'package:doormile_cx/ui/widgets/inputs.dart'; import 'package:doormile_cx/data/doormile_api.dart'; import 'package:doormile_cx/data/location_service.dart'; import 'package:doormile_cx/data/models.dart'; import 'package:doormile_cx/main.dart'; import 'package:doormile_cx/state/app_state.dart'; import 'package:doormile_cx/ui/widgets/buttons.dart'; import 'package:doormile_cx/ui/widgets/map_panel.dart'; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:doormile_cx/data/dev_doormile_api.dart'; /// The app runs continuous animations (radar, live dots, map pulse, skeletons), /// so `pumpAndSettle` would never return. Pump fixed slices instead. Future settle(WidgetTester tester, [int ms = 800]) async { await tester.pump(); await tester.pump(Duration(milliseconds: ms)); await tester.pump(const Duration(milliseconds: 400)); } /// Drains toast and fare-estimate timers so a test ends with nothing pending. Future drainToasts(WidgetTester tester) async { await tester.pump(const Duration(seconds: 3)); } /// The street line currently shown under the pickup map. Empty while the pin /// is still being resolved, which is itself the thing worth asserting. String pickupAddress(WidgetTester tester) { final title = find.byKey(const ValueKey('pickupAddressTitle')); return title.evaluate().isEmpty ? '' : tester.widget(title).data ?? ''; } Future signIn(WidgetTester tester, {AppState? state}) async { // A tall surface so whole screens fit without scrolling in the harness. tester.view.physicalSize = const Size(1230, 9000); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); await tester.pumpWidget(DoormileApp(initialState: state)); // The splash holds for its own minimum — and the waits in front of it — before handing over, so // the entrance is waited out once, here, rather than by lengthening every // settle in the suite. await settle(tester, 4400); await tester.enterText(find.byType(TextField).first, '9876543210'); // The CTA only enables once the field rebuilds with a valid number. await settle(tester, 200); await tester.tap(find.text('Continue')); await settle(tester); // ── A PIN now, not a posted code ── // // The SMS gateway is off: `otp/request` still answers `sent: true` and // writes the code to the server log, so the old four-box screen could never // be completed by a customer. 9876543210 is a number the fake backend knows // with a PIN already set, so this walks the returning-customer path and the // button submits rather than the field auto-verifying. await tester.enterText(find.byType(TextField).first, '1234'); await settle(tester, 200); await tester.tap(find.text('Sign in')); await settle(tester); } /// Moves a booking on the **server**, then lets the app read it back. /// /// This is the only way a stage changes now: the app has no local stage /// machine, so a test cannot step a booking without the backend doing it /// first. Fire-and-pump rather than await — the fake answers on a delay the /// test clock only advances while pumping. Future serverAdvance( WidgetTester tester, AppState state, String reference, JourneyStage stage, ) async { final booking = state.byReference(reference)!; unawaited(state.advanceStage(booking, stage)); await settle(tester, 1400); } /// Home ▸ the BOOK orb ▸ the destination sheet. /// /// BOOK asks one question — where is it going — and the review screen is what /// the answer leads to, so every booking test is [openSend] then [pickCity]. /// The sheet is left open: tests that are about the sheet itself go no /// further. Future openSend(WidgetTester tester) async { await tester.tap(find.text('BOOK NOW')); await settle(tester); } /// A finder restricted to the open bottom sheet. /// /// Home's live card heads with the district alone — "Chennai", not /// "Chennai, Tamil Nadu" — so a bare `find.text('Chennai')` matches the screen /// behind the scrim as well as the row in the sheet. Every assertion about /// what a sheet is offering goes through this. Finder inSheet(Finder matching) => find.descendant(of: find.byType(BottomSheet), matching: matching); /// Answers both questions on the pickup sheet. /// /// ── One sheet now, not two ── /// /// It used to be a destination sheet that closed so a window sheet could open /// behind it, and this helper drove both. `showPickupSheet` asks them on one /// surface, so the second half is a step rather than a new sheet and its /// button reads "Confirm pickup" rather than "Use 2:00 – 4:00 PM". /// /// The `takeWindow: false` branch went with the change: dismissing now drops /// the destination too, because there is only one sheet to dismiss, so a /// booking that reaches Review without a slot is no longer reachable from /// Home. Nothing passed it. Future pickCity(WidgetTester tester, String name) async { await tester.enterText(find.byType(TextField).first, name); await settle(tester, 250); // A `Text` inside the sheet, explicitly. // // `find.text` also matches `EditableText`, so once the city's name is in the // search box the plain finder returns the box as well — and Home, behind the // scrim, heads its live card with the district alone, so an unscoped finder // can land on a widget the sheet is covering. Scoping to the modal route // fixes both. await tester.tap( inSheet(find.byWidgetPredicate((w) => w is Text && w.data == name)).first, ); await settle(tester); // Where the server allows several destinations the row ticks rather than // closing the sheet, so the choice has to be confirmed. Where it caps the // booking at one there is no button and the tap already committed. final send = find.textContaining('Send to '); if (send.evaluate().isNotEmpty) { await tester.tap(send.first); await settle(tester); } // The same sheet, now asking when. final confirm = find.text('Confirm pickup'); if (confirm.evaluate().isNotEmpty) { await tester.tap(confirm.first); await settle(tester); } } /// Opens the pickup-point editor from the send screen's FROM row. Future openPickupEditor(WidgetTester tester) async { await openSend(tester); await pickCity(tester, 'Chennai'); await tester.tap(find.text('PICKUP')); await settle(tester); } /// The fake standing in for the backend in this file. /// /// A fresh one per test, installed as the singleton so the widgets under test /// reach it exactly as they would reach the real API. It is the **only** source /// of bookings here: nothing plants one in [AppState]. late DevDoormileApi api; void main() { setUp(() { api = DevDoormileApi(); DoormileApi.overrideInstance(api); // Widget tests run on a fake clock a real platform-channel reply never // reaches, so the device GPS is replaced with a fixed fix. Without this the // pickup screen would sit on "Getting address…" forever. LocationService.instance = FixedLocationService(); }); tearDown(() => DoormileApi.overrideInstance(null)); testWidgets('a phone number is asked about before a PIN is asked for', (tester) async { // ── The regression this locks ── // // PIN sign-in has three entrances that look identical to a customer: an // unknown number, a known number with no PIN, and a known number with one. // The app asks `POST /auth/login` which it is, and renders that screen. // // Guessing is not cosmetic. Offer "create a PIN" to a returning customer // and the server answers `pin_already_set` on a screen that cannot // succeed; offer "enter your PIN" to somebody who has never set one and // every attempt they make is wrong. tester.view.physicalSize = const Size(1230, 9000); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); await tester.pumpWidget(const DoormileApp()); await settle(tester, 4400); // A number the fake backend knows, with a PIN already set. await tester.enterText(find.byType(TextField).first, '9876543210'); await settle(tester, 200); await tester.tap(find.text('Continue')); await settle(tester); expect(find.text('Enter your PIN'), findsOneWidget); // One field, and it is masked — a PIN is a credential the customer keeps, // unlike a posted code they use once. expect(find.byType(TextField), findsOneWidget); expect( tester.widget(find.byType(DmTextField)).obscure, isTrue, ); // A wrong PIN says so without saying which of the two was wrong. await tester.enterText(find.byType(TextField).first, '9999'); await settle(tester, 200); await tester.tap(find.text('Sign in')); await settle(tester); expect(find.text('That number or PIN is incorrect'), findsOneWidget); expect(find.text('BOOK NOW'), findsNothing); // The right one lands on Home. await tester.enterText(find.byType(TextField).first, '1234'); await settle(tester, 200); await tester.tap(find.text('Sign in')); await settle(tester); expect(find.text('BOOK NOW'), findsOneWidget); await drainToasts(tester); }); testWidgets('a number with no account asks for a name and a new PIN', (tester) async { tester.view.physicalSize = const Size(1230, 9000); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); await tester.pumpWidget(const DoormileApp()); await settle(tester, 4400); await tester.enterText(find.byType(TextField).first, '9111122223'); await settle(tester, 200); await tester.tap(find.text('Continue')); await settle(tester); // Name, PIN, confirm — the sign-up screen's questions, without a second // screen and without an SMS that cannot arrive. expect(find.text('Create your account'), findsOneWidget); expect(find.byType(TextField), findsNWidgets(3)); final fields = find.byType(TextField); await tester.enterText(fields.at(0), 'Meera S'); await tester.enterText(fields.at(1), '4821'); await tester.enterText(fields.at(2), '4821'); await settle(tester, 200); await tester.tap(find.text('Create PIN and continue')); await settle(tester); expect(find.text('BOOK NOW'), findsOneWidget); await drainToasts(tester); }); testWidgets('signs in and lands on Home', (tester) async { await signIn(tester); // Home greets by name, carries the sphere, and ends on the live card. expect(find.text('Hello, Joe'), findsOneWidget); expect(find.text('BOOK NOW'), findsOneWidget); // The seeded active booking surfaces on Home. Its state is the uppercase // micro-label beside the destination, in the chip vocabulary — "Assigned", // not the rail's "Miler assigned". expect(find.text('ASSIGNED'), findsOneWidget); await drainToasts(tester); }); testWidgets('dragging the map re-resolves the pickup address', (tester) async { await signIn(tester); await openPickupEditor(tester); // The pin resolved to a street, not a placeholder. expect(find.text('Getting address…'), findsNothing); final address = pickupAddress(tester); expect(address, isNotEmpty); // Move the map under the fixed pin, the way a customer corrects the door. await tester.drag(find.byType(DmMapPanel), const Offset(-90, -70)); await tester.pump(); // The address goes quiet while the new coordinates are resolved… expect(find.text('Getting address…'), findsOneWidget); // …and comes back as a different place. await settle(tester, 900); expect(find.text('Getting address…'), findsNothing); expect(pickupAddress(tester), isNot(address)); // Which is what the editor carries back to the send screen. final corrected = pickupAddress(tester); await tester.tap(find.text('Confirm pickup point')); await settle(tester); // Back on the send screen: its title is the route, not a heading. expect(find.text('DROP'), findsOneWidget); expect(find.text(corrected), findsOneWidget); await drainToasts(tester); }); testWidgets('the full form asks for the drop address; One Touch does not', (tester) async { final state = AppState(); await signIn(tester, state: state); // One Touch: state, district, window, and nothing else is asked. await openSend(tester); await pickCity(tester, 'Chennai'); expect(state.draftDetailed, isFalse); expect(find.text('DROP ADDRESS'), findsNothing); expect( tester .widget(find.widgetWithText(DmButton, 'Confirm Booking')) .onPressed, isNotNull, ); // Back to Home, and in again through the form's DROP row. await tester.tap(find.byIcon(LucideIcons.arrowLeft).first); await settle(tester); await tester.tap(find.text('DROP')); await settle(tester); // ── Where and when first, the door afterwards ── // // The address sheet used to land between the destination and the window. // Both paths now answer where-and-when on one sheet, and the full form's // extra question — which door — comes after it, next to the review that // shows it. await pickCity(tester, 'Chennai'); expect(state.draftDetailed, isTrue); expect(find.text('Where in Chennai?'), findsOneWidget); // A building or a street is enough to send somebody to. await tester.enterText(find.byType(TextField).first, '3B Lotus Apartments'); await settle(tester, 200); await tester.tap(find.text('Save address')); await settle(tester); // Then the review, which carries the address as a row. expect(find.text('DROP ADDRESS'), findsOneWidget); final group = state.draftDestinations.first; expect(group.details.building, '3B Lotus Apartments'); expect(group.details.exactAddress, isNotNull); expect( tester .widget(find.widgetWithText(DmButton, 'Confirm Booking')) .onPressed, isNotNull, ); await drainToasts(tester); }); testWidgets('the collect-from sheet hands off to the map and back', (tester) async { final state = AppState(); await signIn(tester, state: state); // The PICKUP row of Home's pick/drop form opens the sheet. await tester.tap(find.text('PICKUP')); await settle(tester); expect(find.text('Collect from'), findsOneWidget); // "Select on map" pushes the map screen over the sheet, which stays // mounted underneath. await tester.tap(find.text('Select on map')); await settle(tester); expect(find.text('Confirm pickup point'), findsOneWidget); expect(find.text('PICKUP FROM'), findsOneWidget); // Backing out leaves the customer on the search they came from, not Home. await tester.tap(find.byIcon(LucideIcons.arrowLeft).first); await settle(tester); expect(find.text('Collect from'), findsOneWidget); // Confirming closes both and writes the pin as the pickup. await tester.tap(find.text('Select on map')); await settle(tester); await tester.tap(find.text('Confirm pickup point')); await settle(tester); expect(find.text('Collect from'), findsNothing); expect(state.pickup, isNotNull); expect(state.hasPickupPoint, isTrue); await drainToasts(tester); }); testWidgets('GPS, map drag and search all write one pickup point', (tester) async { final state = AppState( location: FixedLocationService(lat: 11.0183, lng: 76.9725), ); await signIn(tester, state: state); await openPickupEditor(tester); DmMapPanel map() => tester.widget(find.byType(DmMapPanel)); // 1 — the device fix. Address and coordinates land together. final located = state.pickup!; expect(state.hasPickupPoint, isTrue); expect(located.lat, closeTo(11.0183, 0.0001)); expect(map().lat, located.lat); expect(pickupAddress(tester), located.title); // 2 — dragging the map moves the same point. await tester.drag(find.byType(DmMapPanel), const Offset(-140, -110)); await settle(tester, 900); final dragged = state.pickup!; expect(dragged.lat, isNot(located.lat)); expect(dragged.hasLocation, isTrue); expect(map().lat, dragged.lat); expect(pickupAddress(tester), dragged.title); // 3 — a search result overwrites it, coordinates included, and the map // follows the address rather than staying where the drag left it. await tester.tap(find.text('Edit')); await settle(tester); await tester.tap(find.text('Brookefields Mall')); await settle(tester); final searched = state.pickup!; expect(searched.title, 'Brookefields Mall'); expect(searched.lat, closeTo(10.9987, 0.0001)); expect(searched.lng, closeTo(76.9628, 0.0001)); expect(map().lat, searched.lat); expect(map().lng, searched.lng); expect(pickupAddress(tester), 'Brookefields Mall'); // …and that one point is what the booking carries forward. await tester.tap(find.text('Confirm pickup point')); await settle(tester); expect(find.text('DROP'), findsOneWidget); expect(state.draftPickup!.lat, searched.lat); expect(state.draftPickup!.lng, searched.lng); expect(state.draftPickup!.title, 'Brookefields Mall'); await drainToasts(tester); }); testWidgets('the customer is never shown raw coordinates', (tester) async { final state = AppState( location: FixedLocationService(lat: 11.0183, lng: 76.9725), ); await signIn(tester, state: state); await openPickupEditor(tester); // Whatever the pin resolves to, it reads as a place — never a decimal pair. final coordinate = RegExp(r'-?\d{1,3}\.\d{3,}'); for (final text in tester.widgetList(find.byType(Text))) { final data = text.data; if (data == null) continue; expect( coordinate.hasMatch(data), isFalse, reason: 'coordinates leaked into the UI: "$data"', ); } expect(state.pickup!.hasLocation, isTrue); await drainToasts(tester); }); testWidgets('the customer sees their own position as a blue dot', (tester) async { final state = AppState( location: FixedLocationService( lat: 11.0183, lng: 76.9725, accuracy: 22, ), ); await signIn(tester, state: state); await openPickupEditor(tester); final dot = find.byKey(const ValueKey('deviceLocationDot')); expect(dot, findsOneWidget); expect(state.hasDeviceLocation, isTrue); expect(state.deviceLat, closeTo(11.0183, 0.0001)); expect(state.deviceAccuracy, 22); // Dragging moves the pickup pin. It must not move the customer: the dot // stays where they actually are, which is the whole point of showing it. await tester.drag(find.byType(DmMapPanel), const Offset(-150, -120)); await settle(tester, 900); expect(dot, findsOneWidget); expect(state.deviceLat, closeTo(11.0183, 0.0001)); expect(state.deviceLng, closeTo(76.9725, 0.0001)); expect(state.pickup!.lat, isNot(closeTo(11.0183, 0.0001))); await drainToasts(tester); }); testWidgets('no blue dot when the device refused a position', (tester) async { LocationService.instance = FixedLocationService.denied(LocationDenial.serviceOff); final state = AppState(); await signIn(tester, state: state); await openPickupEditor(tester); // A dot in the wrong place is worse than no dot. expect(find.byKey(const ValueKey('deviceLocationDot')), findsNothing); expect(state.hasDeviceLocation, isFalse); await drainToasts(tester); }); testWidgets('a refused location still lets the customer book', (tester) async { LocationService.instance = FixedLocationService.denied(LocationDenial.denied); await signIn(tester); await openPickupEditor(tester); // The map falls back to the service area rather than an empty ocean, and // the refusal is explained instead of silently swallowed. expect( find.textContaining('Allow location access'), findsOneWidget, ); expect(find.text('Search'), findsOneWidget); // Booking is not blocked by it. expect( tester .widget( find.widgetWithText(DmButton, 'Confirm pickup point'), ) .onPressed, isNotNull, ); await drainToasts(tester); }); testWidgets('one screen books a parcel, every optional field skipped', (tester) async { final state = AppState(); await signIn(tester, state: state); await openSend(tester); // BOOK asks one question, and it is the only thing on screen. Scoped to // the sheet: Home's DROP row asks the same question behind the scrim. expect(inSheet(find.text('Where is it going?')), findsOneWidget); expect(find.text('Flat or house number'), findsNothing); await pickCity(tester, 'Chennai'); // The window was the outstanding row on review, and answering it is what // enables the button. expect(find.text('Review and book'), findsOneWidget); // Everything else the booking needs is on one screen, and the route to // the top of it is two rows with no labels but FROM and TO. expect(find.text('PICKUP'), findsOneWidget); expect(find.text('DROP'), findsOneWidget); // Nothing is asked for here at all any more: no address fields, and no // disclosure hiding three more. The route, the packages and the button. expect(find.byType(TextField), findsNothing); expect(find.text('Add landmark or recipient details'), findsNothing); // The customer answered the window sheet on the way in, so the booking // already carries a slot they chose. expect(state.draftSlotId, isNotNull); expect(find.text('Confirm Booking'), findsOneWidget); await tester.tap(find.text('Confirm Booking')); await settle(tester); expect(find.text('Pickup booked'), findsOneWidget); final booking = state.trackedBooking!; expect(booking.destinations, hasLength(1)); expect(booking.totalPackages, 1); expect(booking.destinations.first.trackingId, isNull); // Skipped fields are absent, not empty strings. expect(booking.details.exactAddress, isNull); await drainToasts(tester); }); testWidgets('the pickup contact defaults to the account and can be changed', (tester) async { final state = AppState(); await signIn(tester, state: state); await openPickupEditor(tester); // The contact is folded away — the screen exists to place a pin, and the // number is right nearly every time — so it has to be asked for. await tester.tap(find.text('Someone else is handing it over?')); await settle(tester); // Opens on the signed-in customer's own number, without the country code, // because the field carries its own prefix. final contact = find.widgetWithText(TextField, '9876543210'); expect(contact, findsOneWidget); // Left alone it stays null, so the request carries the account rather than // a copy of it that can drift. await tester.tap(find.text('Confirm pickup point')); await settle(tester); expect(state.draftContactPhone, isNull); // ── Changed, it is a note — not the number the Miler's button dials ── // // That is what this test used to claim. The rider's number is derived by // the backend from the booking's **account**: `GET /miler/bookings` sends // one phone field and nothing the create request carries can change it. // The handover person travels in `remarks` instead, with a name, and both // this screen and Review say so rather than implying otherwise. await tester.tap(find.text('PICKUP')); await settle(tester); await tester.tap(find.text('Someone else is handing it over?')); await settle(tester); // By value, not by position: the fold asks for a name first now, and // `.first` quietly typed the phone number into it. await tester.enterText( find.widgetWithText(TextField, '9876543210'), '9003144518', ); await tester.enterText(find.byType(TextField).first, 'Meera S'); await settle(tester, 200); await tester.tap(find.text('Confirm pickup point')); await settle(tester); expect(state.draftContactPhone, '+91 9003144518'); expect(state.draftContactName, 'Meera S'); // Review shows the account's number as the one that will be called, and // the handover person beside it. expect(find.text('YOUR MILER WILL CALL'), findsOneWidget); // Whatever shape the account's number is in — the dev backend echoes the // typed identifier, production sends E.164 — it is the one on the card. expect(find.text('+91 9876543210'), findsWidgets); expect(find.textContaining('Meera S'), findsWidgets); await drainToasts(tester); }); testWidgets('the package stepper is on the screen and clamps', (tester) async { final state = AppState(); await signIn(tester, state: state); await openSend(tester); await pickCity(tester, 'Coimbatore'); expect(find.text('1 parcel'), findsOneWidget); await tester.tap(find.byIcon(LucideIcons.plus)); await tester.pump(); await tester.tap(find.byIcon(LucideIcons.plus)); await tester.pump(); expect(find.text('3 parcels'), findsOneWidget); expect(state.draftTotalPackages, 3); // Push past the cap on the state; the clamp is the model's, not the UI's. state.setPackageCount(0, 999); await tester.pump(); expect(state.draftTotalPackages, state.limits.maxPackages); await tester.tap(find.text('Confirm Booking')); await settle(tester); expect(state.trackedBooking!.totalPackages, state.limits.maxPackages); await drainToasts(tester); }); testWidgets('the window sheet changes the window', (tester) async { final state = AppState(); await signIn(tester, state: state); await openSend(tester); await pickCity(tester, 'Chennai'); final first = state.draftSlotId; // The window is a row on the review list, under the route. It carries no // label — the clock glyph and the time are the whole row — so it is found // by the window itself. await tester.tap(find.textContaining('2:00 – 4:00 PM').first); await settle(tester); expect(find.text('Pickup window'), findsOneWidget); // A different window, then commit it. await tester.tap(find.text('4:00 – 6:00 PM').first); await tester.pump(); await tester.tap(find.textContaining('Use 4:00 – 6:00 PM')); await settle(tester); expect(state.draftSlotId, isNot(first)); expect(find.text('Pickup window'), findsNothing); await drainToasts(tester); }); testWidgets('the destination step is one flat list, not two levels', (tester) async { await signIn(tester); await openSend(tester); // ── What this replaced ── // // The sheet used to open on states and hide every city until one was // tapped, so the first thing a customer saw was a question about // geography rather than an answer about service. The old test asserted // that Chennai was *absent* from step one. // // Now the state is a heading and the cities are all there. The list is // also the answer to "where do you deliver?", which is why Home's reach // line opens this and not a second screen of its own. expect(inSheet(find.text('TAMIL NADU')), findsOneWidget); expect(inSheet(find.text('KERALA')), findsOneWidget); expect(inSheet(find.text('Chennai')), findsOneWidget); expect(inSheet(find.text('Ernakulam')), findsOneWidget); // Districts that are not open are never offered, and neither is a state // whose every district is closed. expect(inSheet(find.text('Madurai')), findsNothing); expect(inSheet(find.text('Puducherry')), findsNothing); // And search still cuts across states: Ernakulam is in Kerala and the // customer should not have to know that to find it. await pickCity(tester, 'Ernakulam'); expect(find.textContaining('Ernakulam'), findsWidgets); }); testWidgets('orders split into their own rows once collected', (tester) async { final state = AppState(); await signIn(tester, state: state); await tester.tap(find.bySemanticsLabel('Orders')); await settle(tester); // Before collection: one row, owned by the pickup. The reference shares a // line with the date and the contents now, so it is a span inside a // paragraph rather than a Text of its own. expect(find.textContaining('DM-482913'), findsOneWidget); // After collection: the seeded multi-destination pickup is in transit, so // each destination stands on its own row. final booking = state.byReference('DM-471200')!; expect(booking.hasOrders, isTrue); expect(find.text('Pickup DM-471200'), findsNothing); expect(find.text('Ernakulam'), findsOneWidget); expect(find.text('Coimbatore'), findsOneWidget); // Each row is identified by its OWN tracking number, not by the visit that // produced it. "Collected on DM-471200" used to run under both rows; it // answers a question asked about an order, not while scanning a list of // them, so it moved to the order's own screen. expect(find.text('Collected on DM-471200'), findsNothing); for (final group in booking.destinations) { expect(find.textContaining(group.trackingId!), findsOneWidget); } // Independently tracked. expect( booking.destinations.map((d) => d.trackingId).toSet().length, booking.destinations.length, ); // Opening one follows that order, not the whole visit: the head card's // route ends at that destination, and the reference is its own tracking id. await tester.tap(find.text('Ernakulam')); await settle(tester); expect(find.text('Ernakulam, Kerala'), findsWidgets); expect(find.text('YOUR PACKAGES'), findsOneWidget); expect(find.text('YOUR PACKAGES'), findsOneWidget); await drainToasts(tester); }); testWidgets('the timeline is the shipment, not the pickup visit', (tester) async { final state = AppState(); await signIn(tester, state: state); // The live card, by its reference. Home's quick-dispatch chips are // named after cities too, so a bare 'Chennai' finder now hits a // shortcut that starts a new booking instead. await tester.tap(find.text('DM-482913')); await settle(tester); // The whole shipment is on screen the moment tracking opens — five rows // fit where the three-row summary and its "Full timeline" action used to. expect(find.text('Full timeline'), findsNothing); // Five shipment milestones. Everything before the order exists is one // row — the whole pickup visit, which the live card above narrates by // name, with a distance and an ETA. expect(find.text('Pickup booked'), findsOneWidget); expect(find.text('Order created'), findsOneWidget); expect(find.text('In transit'), findsOneWidget); expect(find.text('Out for delivery'), findsOneWidget); expect(find.text('Delivered'), findsOneWidget); // The visit's own stages are never rows of their own. expect(find.text('Pickup in progress'), findsNothing); expect(find.text('Package collected'), findsNothing); expect(find.text('Miler arrived'), findsNothing); // Advancing through the visit does not add a row — it moves the detail // under the one row the whole visit occupies. await serverAdvance(tester, state, 'DM-482913', JourneyStage.onTheWay); expect(find.text('Pickup booked'), findsOneWidget); expect(find.textContaining('Miler on the way'), findsWidgets); await drainToasts(tester); }); testWidgets('packages stay together until the pickup is actually collected', (tester) async { final state = AppState(); await signIn(tester, state: state); // The live card, by its reference. Home's quick-dispatch chips are // named after cities too, so a bare 'Chennai' finder now hits a // shortcut that starts a new booking instead. await tester.tap(find.text('DM-482913')); await settle(tester); // The head card names the state in a pill and draws the route as a rail. // It used to be a filled crimson card headed "LIVE" — crimson is the // action colour, and tracking is a screen you read. expect(find.text('Miler assigned'), findsWidgets); expect(find.text('12 Nehru Street'), findsWidgets); expect(find.text('Cancel this pickup'), findsOneWidget); // No per-package breakdown before collection. expect(find.text('YOUR PACKAGES'), findsNothing); await serverAdvance(tester, state, 'DM-482913', JourneyStage.pickedUp); // Custody has changed hands: cancellation is no longer offered. expect(find.text('Cancel this pickup'), findsNothing); await serverAdvance(tester, state, 'DM-482913', JourneyStage.orderCreated); final booking = state.byReference('DM-482913')!; expect(booking.stage, JourneyStage.orderCreated); expect(booking.destinations.first.trackingId, isNotNull); await drainToasts(tester); }); testWidgets('cancels the whole pickup from tracking', (tester) async { final state = AppState(); await signIn(tester, state: state); // The live card, by its reference. Home's quick-dispatch chips are // named after cities too, so a bare 'Chennai' finder now hits a // shortcut that starts a new booking instead. await tester.tap(find.text('DM-482913')); await settle(tester); await tester.tap(find.text('Cancel this pickup')); await settle(tester); expect(find.text('Cancel this pickup?'), findsOneWidget); await tester.tap(find.text('Package not ready')); await tester.pump(); await tester.tap(find.text('Yes, cancel pickup')); await settle(tester); // The head card carries the state in its pill now, not as a headline. expect(find.textContaining('Cancelled'), findsWidgets); final booking = state.byReference('DM-482913')!; expect(booking.status, BookingStatus.cancelled); await drainToasts(tester); }); testWidgets('shows the empty service-area state', (tester) async { api.flags.emptyServiceAreas = true; await signIn(tester); await openSend(tester); expect(find.text('No cities open yet'), findsOneWidget); await drainToasts(tester); }); testWidgets('shows the network error state with retry', (tester) async { final state = AppState(); await signIn(tester, state: state); // ── The cache has to be cold for the error to be reachable ── // // Signing in now warms the serviceable cities, and the destination sheet // renders `cachedCities` as its `initialItems` rather than a skeleton — so // with a warm cache and no network it shows the list it already has, which // is the right behaviour and not what this test is about. Clearing the // caches puts it back in the state a first-ever open is in. state.statesCache = null; state.districtCache.clear(); api.flags.networkError = true; await openSend(tester); expect(find.text("Couldn't load this"), findsOneWidget); api.flags.networkError = false; await tester.tap(find.text('Retry')); await settle(tester); // It comes back with the list it failed to load, not a blank sheet. The // state is a heading now — upper-cased and not a step to tap through — so // the cities are there without a second navigation. expect(find.text('TAMIL NADU'), findsOneWidget); expect(find.text('Coimbatore'), findsWidgets); expect(find.text('Chennai'), findsWidgets); await drainToasts(tester); }); testWidgets('a delivered order opens its receipt', (tester) async { await signIn(tester); await tester.tap(find.bySemanticsLabel('Orders')); await settle(tester); await tester.tap(find.textContaining('Past')); await settle(tester); await tester.tap(find.text('Salem')); await settle(tester); expect(find.text('Delivered'), findsWidgets); expect(find.text('Total paid'), findsOneWidget); expect(find.text('₹64'), findsWidgets); // What the Miler recorded at the door. expect(find.text('COLLECTED AT YOUR DOOR'), findsOneWidget); expect(find.text('Miler verified'), findsOneWidget); // Once on the parcel block, once in the payment breakdown. expect(find.text('1.6 kg'), findsNWidgets(2)); expect(find.text('Verified weight'), findsOneWidget); expect(find.text('1 of 1'), findsOneWidget); expect(find.textContaining('Weighed and photographed by'), findsOneWidget); await drainToasts(tester); }); testWidgets('key screens survive large accessibility text', (tester) async { // Any overflow throws, so simply building these screens is the assertion. tester.platformDispatcher.textScaleFactorTestValue = 1.8; addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); await signIn(tester); // login + OTP expect(find.text('BOOK NOW'), findsOneWidget); // home await openSend(tester); // the whole booking, one screen await pickCity(tester, 'Chennai'); // The pickup map: a fixed-height sheet over a full-bleed map, so large // text has nowhere to grow into. Opening the contact fold is the tallest // it ever gets. await tester.tap(find.text('PICKUP')); await settle(tester); await tester.tap(find.text('Someone else is handing it over?')); await settle(tester); await tester.tap(find.text('Confirm pickup point')); await settle(tester); await tester.tap(find.text('Confirm Booking')); await settle(tester); // confirmation await tester.tap(find.text('Track pickup')); await settle(tester); // tracking // The live card names the stage; the rail names the shipment milestone. expect(find.text('Pickup booked'), findsWidgets); await drainToasts(tester); }); testWidgets('creating an account survives large accessibility text', (tester) async { tester.platformDispatcher.textScaleFactorTestValue = 1.8; addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); 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); // ── There is no separate sign-up screen any more ── // // It asked for a name and then sent an SMS code, which cannot arrive with // the gateway off. A new number is recognised by `POST /auth/login` and // the PIN screen asks for the name and the PIN together, so this walks the // real entrance instead of a "Create account" link that no longer exists. await tester.enterText(find.byType(TextField).first, '9111122223'); await settle(tester, 200); await tester.tap(find.text('Continue')); await settle(tester); // Building it is the assertion: any overflow in the hero or the form // throws. expect(find.text('Create your account'), findsOneWidget); await drainToasts(tester); }); testWidgets('orders and account survive large accessibility text', (tester) async { tester.platformDispatcher.textScaleFactorTestValue = 1.8; addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); await signIn(tester); await tester.tap(find.bySemanticsLabel('Orders')); await settle(tester); // At 1.8x the three tabs are wider than the screen, so the second one has // to be scrolled to before it can be tapped. await tester.ensureVisible(find.textContaining('Past')); await tester.pump(); await tester.tap(find.textContaining('Past')); await settle(tester); await tester.tap(find.text('Salem')); await settle(tester); // receipt await drainToasts(tester); }); testWidgets('orders tabs filter by status', (tester) async { final state = AppState(); await signIn(tester, state: state); await tester.tap(find.bySemanticsLabel('Orders')); await settle(tester); // ── Label and count are separate widgets now ── // // The filter used to fold the figure into the label's own paragraph // ("Active (4)"), so this matched one span. It is [DmChoiceChip] now — // the same control as the pickup window's day switcher — which puts the // count in a bubble beside the label. Two Texts, not one string. // // The figure is still the rows that tab will show, which for a collected // multi-destination pickup is one per destination. expect(find.text('Active'), findsOneWidget); expect( find.text('${state.entriesOf(BookingStatus.active).length}'), findsWidgets, ); // The tab row scrolls — three labels plus their counts do not fit a 390pt // phone at large text sizes. await tester.ensureVisible(find.textContaining('Cancelled').first); await tester.pump(); await tester.tap(find.textContaining('Cancelled').first); await settle(tester); expect(find.text('Erode'), findsOneWidget); await drainToasts(tester); }); }