import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/data/accepted_store.dart'; import 'package:miler/data/service_profile.dart'; import 'package:slide_to_submit_button/slide_to_submit_button.dart'; import 'package:miler/Models/stop_status.dart'; import 'package:miler/data/bag_manifest.dart'; import 'package:miler/views/Dashboard/home/stop_action_sheet.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/home/selection_bar.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/Dashboard/home/trip_card.dart'; /// P3/P4 — the collect-then-deliver run. /// /// The rule the whole flow hangs on: **Home holds an order until it is in the /// rider's hands, Bookings holds it after.** Every test here is a way that rule /// can break. void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() => SharedPreferences.setMockInitialValues({})); tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); Map meal({ required String id, required int step, required String kitchen, String kitchenId = '', String status = 'ASSIGNED', }) => { 'orderid': id, 'step': step, 'pickupcustomer': 'Customer $id', 'pickupaddress': '$step Main Road, Anna Nagar', 'orderstatus': status, 'type': 'delivery', 'quantity': 1, 'kitchenname': kitchen, if (kitchenId.isNotEmpty) 'kitchenid': kitchenId, 'baglabel': 'DG-$id', 'pickuplat': 13.08, 'pickuplon': 80.21, }; group('the collected state', () { final stop = meal(id: '1', step: 1, kitchen: 'Kitchen 1'); test('carrying it beats a server view that still says accepted', () { // The refetch is always a poll or two behind the rider's own hands. If // the server won here the card would bounce back onto Home after he had // already left the kitchen with the food. final state = stopStateOf( {...stop, 'orderstatus': 'accepted'}, acceptedIds: {'1'}, rejectedIds: const {}, collectedIds: {'1'}, ); expect(state, StopState.collected); }); test('but a delivered stop is still delivered', () { // `picked` is the hand-over at the door and it is terminal — nothing // local may drag it backwards into "still carrying it". final state = stopStateOf( {...stop, 'orderstatus': 'picked'}, acceptedIds: const {}, rejectedIds: const {}, collectedIds: {'1'}, ); expect(state, StopState.done); }); test('a parcel route never sees the state at all', () { final state = stopStateOf( stop, acceptedIds: {'1'}, rejectedIds: const {}, // Default empty — no Doormile call site passes this. ); expect(state, StopState.accepted); }); test('it counts as committed but not as resolved', () { // Committed: he owes somebody a delivery. Not resolved: the trip is not // finished, and a ring that read 100% with ten meals in the box would be // telling him he could go home. expect(StopState.collected.isCommitted, isTrue); expect(StopState.collected.isResolved, isFalse); }); }); group('Home holds it until it is in his hands', () { test('an accepted meal stays on Home; an accepted parcel does not', () { ServiceProfile.setActive(ServiceProfile.meals); final trip = Trip.fromStops( id: 't', stops: [meal(id: '1', step: 1, kitchen: 'Kitchen 1')], ); final card = TripCard(trip: trip, acceptedIds: const {'1'}); // Rendering is asserted below; this is the rule underneath it. expect(ServiceProfile.active.handsOffAtCollection, isTrue); expect(card.trip.stops.length, 1); ServiceProfile.setActive(ServiceProfile.parcel); expect(ServiceProfile.active.handsOffAtCollection, isFalse); }); testWidgets('the row is on Home when accepted, gone once collected', ( tester, ) async { ServiceProfile.setActive(ServiceProfile.meals); tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final trip = Trip.fromStops( id: 't', stops: [meal(id: '1', step: 1, kitchen: 'Kitchen 1')], ); Future pump(Set collected) async { await tester.pumpWidget( ScreenUtilInit( designSize: const Size(390, 844), builder: (_, _) => MaterialApp( home: Scaffold( body: SingleChildScrollView( child: TripCard( trip: trip, acceptedIds: const {'1'}, collectedIds: collected, ).buildStops(), ), ), ), ), ); await tester.pumpAndSettle(); } await pump(const {}); expect(find.text('Customer 1'), findsOneWidget); expect(find.text('Accepted'), findsOneWidget); // Collected → it belongs to Bookings now. await pump({'1'}); expect(find.text('Customer 1'), findsNothing); }); }); group('kitchen grouping', () { testWidgets('one heading per kitchen, in route order', (tester) async { ServiceProfile.setActive(ServiceProfile.meals); tester.view.physicalSize = const Size(1080, 3000); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final trip = Trip.fromStops( id: 't', stops: [ meal(id: '1', step: 1, kitchen: 'Kitchen 1', kitchenId: 'K1'), meal(id: '2', step: 2, kitchen: 'Kitchen 1', kitchenId: 'K1'), meal(id: '3', step: 3, kitchen: 'Kitchen 2', kitchenId: 'K2'), ], ); await tester.pumpWidget( ScreenUtilInit( designSize: const Size(390, 844), builder: (_, _) => MaterialApp( home: Scaffold( body: SingleChildScrollView( child: TripCard( trip: trip, acceptedIds: const {'1', '2', '3'}, ).buildStops(), ), ), ), ), ); await tester.pumpAndSettle(); // Two kitchens, named once each however many meals hang off them. expect(find.text('Kitchen 1'), findsOneWidget); expect(find.text('Kitchen 2'), findsOneWidget); // No second bulk control up here: the heading gathers the counter's // orders for the bar, and the bar is the only thing that commits. expect(find.textContaining('Collect '), findsNothing); // One order, one bag — the two numbers the rider checks against the // shelf, and they are the same number by definition. See [BagManifest]. expect(find.text('2 bags'), findsOneWidget); expect(find.text('1 bag'), findsOneWidget); }); testWidgets('a heading gathers one rung, never a mixed batch', ( tester, ) async { ServiceProfile.setActive(ServiceProfile.meals); tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); List? picked; final trip = Trip.fromStops( id: 't', stops: [ meal(id: '1', step: 1, kitchen: 'Kitchen 1'), meal(id: '2', step: 2, kitchen: 'Kitchen 1'), ], ); await tester.pumpWidget( ScreenUtilInit( designSize: const Size(390, 844), builder: (_, _) => MaterialApp( home: Scaffold( body: SingleChildScrollView( child: TripCard( trip: trip, // Only one of the two taken on. acceptedIds: const {'1'}, // The group's select-all is a *selection-mode* control now — // it appears once the rider has ticked something and leaves // when he has not, rather than sitting in every kitchen all // day. Order 2 is ticked here, which puts the run in that // mode without touching the accepted rung this test is about. selectedIds: const {'2'}, onToggleSelect: (_) {}, onSelectAll: (ids) => picked = ids, ).buildStops(), ), ), ), ), ); await tester.pumpAndSettle(); // The header still says how much of the counter is not his yet. expect(find.text('2 bags · 1 to accept'), findsOneWidget); // And its tick gathers only the orders on one rung. He cannot be handed // food for an order he has not taken on, so a heading that swept both // into one selection would offer a verb that is wrong for half of them — // see `_selectionAction`, which falls back to Accept on a mixed set. // Ported: the kitchen heading's tick became the group's one selection // control. Same job, same rule — it gathers the counter, on one rung. await tester.tap(find.textContaining('Select all')); await tester.pumpAndSettle(); expect(picked, ['1'], reason: 'only the accepted one'); }); }); // ── The pickup confirmation is the only place a handover is confirmed ── // // There used to be two bulk flows at a counter: this ladder, and a separate // `Collect N` sheet with its own tick-list. They wrote different state and // taught two mental models for one physical act. The ladder won; these tests // are the load sheet's contracts, moved onto the sheet that absorbed them — // the short pick, the empty crate, and the split between what he took and // what he did not. group('the pickup confirmation', () { Future open( WidgetTester tester, int count, { List markMissing = const [], }) async { ServiceProfile.setActive(ServiceProfile.meals); tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); RungOutcome? seen; final stops = [ for (var i = 1; i <= count; i++) meal(id: '$i', step: i, kitchen: 'Kitchen 1'), ]; await tester.pumpWidget( ScreenUtilInit( designSize: const Size(390, 844), builder: (_, _) => MaterialApp( home: Scaffold( body: StopActionSheet( stage: StopStatus.arrived, placeName: 'Kitchen 1', count: stops.length, lines: BagManifest.forGroup(stops), onConfirm: (outcome) async { seen = outcome; return null; }, ), ), ), ), ); await tester.pumpAndSettle(); if (markMissing.isNotEmpty) { await tester.tap(find.text('Report missing bag')); await tester.pumpAndSettle(); for (final name in markMissing) { await tester.tap(find.text(name)); await tester.pumpAndSettle(); } } return seen; } testWidgets('nothing is marked by default — the exception is what he marks', ( tester, ) async { await open(tester, 3); // A screen that makes him tick three boxes to record "nothing was wrong" // is a screen he learns to dismiss without reading. So the common case is // a list he reads, and the shortfall is one tap away. expect(find.text('3 bags'), findsOneWidget); expect(find.text('Report missing bag'), findsOneWidget); expect(find.text('Missing'), findsNothing); expect( find.text('Make sure all 3 bags are loaded before continuing.'), findsOneWidget, ); }); testWidgets('marking one reports a short pick, and says so', ( tester, ) async { await open(tester, 3, markMissing: ['Customer 2']); expect(find.text('2 bags · 1 missing'), findsOneWidget); expect(find.text('Missing'), findsOneWidget); expect( find.text('1 of 3 not supplied — they will leave your route.'), findsOneWidget, ); expect(find.text('Slide to confirm pickup'), findsOneWidget); }); testWidgets('taking nothing at all is not a collection', (tester) async { await open(tester, 2, markMissing: ['Customer 1', 'Customer 2']); // An empty crate is a wasted trip and a call to the hub, not a state to // file silently. The gesture goes dead and the sheet says why. expect(find.text('Nothing to collect'), findsOneWidget); expect( find.text('Nothing was handed over — call the hub before you leave.'), findsOneWidget, ); final gate = tester.widget( find .ancestor( of: find.byType(SlideToSubmit), matching: find.byType(IgnorePointer), ) .first, ); expect(gate.ignoring, isTrue); }); testWidgets('the outcome splits what he took from what he did not', ( tester, ) async { RungOutcome? captured; ServiceProfile.setActive(ServiceProfile.meals); tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final stops = [ meal(id: '1', step: 1, kitchen: 'Kitchen 1'), meal(id: '2', step: 2, kitchen: 'Kitchen 1'), ]; final sheet = StopActionSheet( stage: StopStatus.arrived, placeName: 'Kitchen 1', count: stops.length, lines: BagManifest.forGroup(stops), onConfirm: (outcome) async { captured = outcome; return null; }, ); await tester.pumpWidget( ScreenUtilInit( designSize: const Size(390, 844), builder: (_, _) => MaterialApp(home: Scaffold(body: sheet)), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('Report missing bag')); await tester.pumpAndSettle(); await tester.tap(find.text('Customer 2')); await tester.pumpAndSettle(); // Drive the confirmation the way the slide does, without simulating a // 300px drag: what is pinned here is the *payload*, not the gesture. final state = tester.state(find.byType(StopActionSheet)); // ignore: avoid_dynamic_calls await (state as dynamic).confirmForTest(); await tester.pumpAndSettle(); expect(captured, isNotNull); expect(captured!.missingOrderIds, ['2']); expect(captured!.isClean, isFalse); }); }); group('the collected store', () { test('records, then clears once delivered', () async { await addCollectedOrderIds(['1', '2']); expect(await getCollectedOrderIds(), {'1', '2'}); await removeCollectedOrderIds(['1']); expect(await getCollectedOrderIds(), {'2'}); }); test('a short pick is its own record, not a skip', () async { await addNotLoadedOrderIds(['9']); expect(await getNotLoadedOrderIds(), {'9'}); // Nobody was visited and the customer did nothing wrong, so it must not // land in the skip store where it would read as a failed doorstep. expect(await getSkippedOrderIds(), isEmpty); }); test('a day can be wiped so ids never leak into tomorrow', () async { await addCollectedOrderIds(['1']); await addNotLoadedOrderIds(['2']); await clearServiceRunState(); expect(await getCollectedOrderIds(), isEmpty); expect(await getNotLoadedOrderIds(), isEmpty); }); }); group('accept does not hand the meal over to Bookings', () { /// The gate Bookings applies to decide what the rider is working now. /// Mirrors the filter in `pickups.dart` so the rule can be asserted without /// standing up the whole tab. Future> workableIds() async => ServiceProfile.active.handsOffAtCollection ? await getCollectedOrderIds() : await getAcceptedOrderIds(); test( 'accepted-but-not-collected is not workable on a service run', () async { ServiceProfile.setActive(ServiceProfile.meals); // Accepting writes to the accepted store — that is how the stop knows it // has been taken on, and Home needs it to render "Accepted". await addAcceptedBookings([ {'orderid': '1', 'orderstatus': 'accepted'}, ]); // But he is carrying nothing yet, so Bookings must be empty. Letting // accept fill that tab hands him a delivery list for food still being // cooked, and empties Home where the collection is organised. expect(await workableIds(), isEmpty); await addCollectedOrderIds(['1']); expect(await workableIds(), {'1'}); }, ); test('a parcel is workable the moment it is accepted', () async { ServiceProfile.setActive(ServiceProfile.parcel); await addAcceptedBookings([ {'orderid': '9', 'orderstatus': 'accepted'}, ]); // Unchanged behaviour: for a parcel, accepting IS the hand-off. expect(await workableIds(), {'9'}); }); test('the two stores stay independent', () async { ServiceProfile.setActive(ServiceProfile.meals); await addAcceptedBookings([ {'orderid': '1', 'orderstatus': 'accepted'}, {'orderid': '2', 'orderstatus': 'accepted'}, ]); await addCollectedOrderIds(['1']); // Both taken on; only one in his hands. Home keeps the second, Bookings // takes the first. expect(await getAcceptedOrderIds(), {'1', '2'}); expect(await getCollectedOrderIds(), {'1'}); }); }); group('the ladder, worked from the selection bar', () { // A rider at a kitchen with five parcels takes one handover, so he ticks // the five and slides once per rung. The row briefly carried its own // Arrived / Picked up buttons — five buttons pressed twice for one event — // and they are gone. test('each rung names itself in the rider\'s voice', () { // "Mark as…" rather than "I've…": the button opens a sheet that then // asks him to confirm the same words, so the two read as one action. expect(SelectionAction.accept.label, 'Accept'); expect(SelectionAction.arrive.label, 'Mark as Arrived'); expect(SelectionAction.pickUp.label, 'Mark as Picked'); }); test('each rung writes the right status token', () { expect(SelectionAction.accept.status, 'ACCEPTED'); expect(SelectionAction.arrive.status, 'ARRIVED'); expect(SelectionAction.pickUp.status, 'PICKED'); }); test( 'the two that move parcels are confirmed in a sheet; accepting is not', () { // Accepting is reversible and happens in bulk at the start of a shift, so // it commits from the bar. "Picked" is the rider asserting he is holding // somebody's lunch — that one gets a sheet with a slide in it, where // there is room to say which counter and how many orders he is speaking // for. The slide used to be squeezed inline onto the bar itself. expect(SelectionAction.accept.confirmsInSheet, isFalse); expect(SelectionAction.arrive.confirmsInSheet, isTrue); expect(SelectionAction.pickUp.confirmsInSheet, isTrue); }, ); Future pumpRow( WidgetTester tester, { required String status, Set accepted = const {'1'}, }) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final trip = Trip.fromStops( id: 't', stops: [meal(id: '1', step: 1, kitchen: 'Kitchen 1', status: status)], ); await tester.pumpWidget( ScreenUtilInit( designSize: const Size(390, 844), builder: (_, _) => MaterialApp( home: Scaffold( body: SingleChildScrollView( child: TripCard( trip: trip, acceptedIds: accepted, onToggleSelect: (_) {}, ).buildStops(), ), ), ), ), ); await tester.pumpAndSettle(); } // ── The rung is pressed on the bar and *committed* in a sheet ── // // The bar's job at this point in the shift is to be unmissable: he is // holding a crate at a counter. The commit's job is to be deliberate. Those // are opposite requirements, which is why they are now two surfaces — // a full-width button that opens [StopActionSheet], and a slide inside it. Future pumpBar( WidgetTester tester, { required SelectionAction action, required VoidCallback onAdvance, }) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( ScreenUtilInit( designSize: const Size(390, 844), builder: (_, _) => MaterialApp( home: Scaffold( body: Align( alignment: Alignment.bottomCenter, child: SelectionBar( count: 3, action: action, onAccept: null, onClear: () {}, onAdvance: () async => onAdvance(), ), ), ), ), ), ); await tester.pumpAndSettle(); } testWidgets('the bar names the rung and how many it covers', ( tester, ) async { ServiceProfile.setActive(ServiceProfile.meals); await pumpBar(tester, action: SelectionAction.arrive, onAdvance: () {}); expect(find.text('Mark as Arrived (3)'), findsOneWidget); // Nothing shares the bar with it: a stop already taken on cannot be // rejected from here, and the count is on the button itself. expect(find.text('Reject'), findsNothing); expect(find.textContaining('selected'), findsNothing); }); testWidgets('the pick-up rung is the same control, renamed', ( tester, ) async { ServiceProfile.setActive(ServiceProfile.meals); await pumpBar(tester, action: SelectionAction.pickUp, onAdvance: () {}); expect(find.text('Mark as Picked (3)'), findsOneWidget); }); testWidgets('pressing it hands off rather than writing anything', ( tester, ) async { ServiceProfile.setActive(ServiceProfile.meals); var opened = 0; await pumpBar( tester, action: SelectionAction.arrive, onAdvance: () => opened++, ); await tester.tap(find.text('Mark as Arrived (3)')); await tester.pumpAndSettle(); // The press opens the confirmation; the slide inside it is what commits. expect(opened, 1); }); testWidgets('a kitchen heading ticks every order off that counter', ( tester, ) async { ServiceProfile.setActive(ServiceProfile.meals); tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); List? picked; final trip = Trip.fromStops( id: 't', stops: [ meal( id: '1', step: 1, kitchen: 'Kitchen 1', kitchenId: 'K1', status: 'accepted', ), meal( id: '2', step: 2, kitchen: 'Kitchen 1', kitchenId: 'K1', status: 'accepted', ), meal( id: '3', step: 3, kitchen: 'Kitchen 2', kitchenId: 'K2', status: 'accepted', ), ], ); await tester.pumpWidget( ScreenUtilInit( designSize: const Size(390, 844), builder: (_, _) => MaterialApp( home: Scaffold( body: SingleChildScrollView( child: TripCard( trip: trip, acceptedIds: const {'1', '2', '3'}, // Selection mode, entered on the *other* kitchen — which also // sharpens the assertion below: Kitchen 2 has a live tick of // its own, and gathering Kitchen 1 must not disturb it. selectedIds: const {'3'}, onToggleSelect: (_) {}, onSelectAll: (ids) => picked = ids, ).buildStops(), ), ), ), ), ); await tester.pumpAndSettle(); // He arrives at a kitchen, not at an order — so the group's own control // is where the whole counter is gathered for the bar underneath. await tester.tap(find.textContaining('Select all')); await tester.pumpAndSettle(); expect(picked, ['1', '2'], reason: 'the other kitchen is left alone'); }); testWidgets('the row states where the stop is and offers no control', ( tester, ) async { ServiceProfile.setActive(ServiceProfile.meals); await pumpRow(tester, status: 'accepted'); expect(find.text('Customer 1'), findsOneWidget); expect(find.text('Mark as Arrived'), findsNothing); expect(find.text('Arrived'), findsNothing); expect(find.text('Picked up'), findsNothing); }); testWidgets('the tick survives the accept, so the set can be slid', ( tester, ) async { ServiceProfile.setActive(ServiceProfile.meals); await pumpRow(tester, status: 'accepted'); // Without this the bar disappears the instant he accepts the very stops // he is about to walk into a kitchen with. expect(find.byType(Checkbox), findsNothing); // it is a custom box // Ported: the card's tick box became the timeline row itself, which // announces as a selection control while one is meaningful. Same // contract — the affordance outlives the accept on a meal run. expect(find.bySemanticsLabel(RegExp('^Select ')), findsOneWidget); }); testWidgets('a parcel row loses its tick once accepted', (tester) async { ServiceProfile.setActive(ServiceProfile.parcel); await pumpRow(tester, status: 'accepted'); // Ticking exists only to choose what to take on. expect(find.bySemanticsLabel(RegExp('^Select ')), findsNothing); }); testWidgets('picked up leaves Home', (tester) async { ServiceProfile.setActive(ServiceProfile.meals); await pumpRow(tester, status: 'picked'); expect(find.text('Customer 1'), findsNothing); }); }); group('the doorstep chain', () { // "I've handed over" → next order. Everything that used to sit between // those two is gated off, and each gate is a different mechanism, so they // are asserted together rather than trusted individually. test('nothing stands between the hand-over and the next order', () { ServiceProfile.setActive(ServiceProfile.meals); final p = ServiceProfile.active; // The proof-of-work page — parcel ticks, weight, condition, OTP, review. expect(p.needsVerification, isFalse); // The camera on the final rung. expect(p.needsProofPhoto, isFalse); // The payment page, and the amount that would open it. expect(p.collectsCash, isFalse); expect(stopCollectionAmount({'collectionamt': 450}), 0); expect(stopCollectionAmount({'codamount': '₹1,200'}), 0); }); test('a parcel stop keeps every one of them', () { ServiceProfile.setActive(ServiceProfile.parcel); final p = ServiceProfile.active; expect(p.needsVerification, isTrue); expect(p.needsProofPhoto, isTrue); expect(p.collectsCash, isTrue); expect(stopCollectionAmount({'collectionamt': 450}), 450); }); }); }