import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:miler/data/order_events.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/views/Dashboard/activity/delivery_details_page.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; /// ───────────────────────────────────────────────────────────────────────── /// DELIVERY DETAILS — one stop, the whole story /// /// The record used to open inside the Activity list. It is a page now, for two /// reasons: an open record is several screens tall, so the list stopped being /// scannable the moment it was used — and the two jobs want opposite things. /// The list answers "did that go through?" in one word per row; this answers /// "what happened on that one?", which is a timeline, a route, four /// measurements and a reference. /// /// The page takes the record it is given and renders it — no fetch, no store /// write — so these pump it directly. /// /// What they hold is the part that regresses silently: **a rung, a row or a /// whole section is drawn only when the data behind it exists.** A record with /// a hole in it can be investigated; one carrying a fabricated `0 km` cannot be /// caught. /// ───────────────────────────────────────────────────────────────────────── void main() { final now = DateTime(2026, 8, 20, 11, 42); /// A finished stop. Every stamp is optional, exactly as it is in the store. Map record({ String type = 'delivery', String status = 'delivered', bool started = true, bool arrived = true, Map? compliance, double cash = 0, double? collected, String? skipReason, }) => { 'orderid': 'DM101', 'type': type, 'pickupcustomer': 'Joe Mathew', 'sourcename': 'Vidhya Kitchen', 'pickupaddress': '78, Cross Cut Road, Gandhipuram', 'orderstatus': status, 'completedat': now.toIso8601String(), if (started) 'startedat': now.subtract(const Duration(minutes: 87)).toIso8601String(), if (arrived) 'arrivedat': now.subtract(const Duration(minutes: 5)).toIso8601String(), if (compliance != null) 'compliance': compliance, if (cash > 0) 'collectionamt': '$cash', if (skipReason != null) 'skipreason': skipReason, 'proof': { 'collected': 2, 'weight': '2.4', if (collected != null) 'amountcollected': collected, }, }; Future pump(WidgetTester tester, Map stop) async { // The record is a tall page and the `ListView` under it is lazy, so a // default 800×600 harness never builds the sections below the timeline — // and every assertion about them would pass or fail on viewport height // rather than on the widget. Give it a phone's width and room to render. tester.view.physicalSize = const Size(390 * 3, 2600 * 3); tester.view.devicePixelRatio = 3; addTearDown(tester.view.reset); await tester.pumpWidget( ScreenUtilInit( designSize: const Size(390, 844), builder: (_, _) => MaterialApp(home: DeliveryDetailsPage(stop: stop)), ), ); await tester.pumpAndSettle(); } /// Opens every folded group. /// /// ── Why the tests now tap first ── /// /// The ledger — distance, ETA, payment, the booking reference — used to be /// drawn flat, at the same weight as the outcome, which is what made a /// finished delivery read as a form to work through. It is folded now: three /// rows the rider taps only when somebody in an office asks him something. /// /// The facts are unchanged and every assertion about them still holds. What /// changed is that reaching them costs a tap, so the tests take it — the /// alternative is asserting the *absence* of progressive disclosure, which /// is the thing being introduced. Future unfold(WidgetTester tester) async { for (final title in const [ 'Delivery details', 'Payment', 'Order details', ]) { // Matched inside its own control, not by text alone: the app bar's // title is *also* "Delivery details", and a finder that catches both // makes `tap` throw rather than fail an expectation — which is how one // ambiguous line reads as nine unrelated broken tests. final row = find.descendant( of: find.byType(InkWell), matching: find.text(title), ); if (row.evaluate().isEmpty) continue; await tester.tap(row.first); await tester.pumpAndSettle(); } } tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); group('the summary', () { testWidgets('leads with the outcome, in the outcome\'s colour', ( tester, ) async { ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, record()); // The word comes from `StopStatusX.label` — the canonical list — so a // stop cannot be called one thing on the list and another on the page // that list opens. It appears twice: the summary and the timeline's head // rung, which is the newest event. expect(find.text('Delivered'), findsNWidgets(2)); final headline = tester.widget(find.text('Delivered').first); expect(headline.style!.color, ColorConstants.acceptGreen); // Bigger than the route under it, which is bigger than the note under // that: the hierarchy is the answer, then which stop, then the detail. // The route is two `Text`s with a glyph between them — Poppins carries no // U+2192 and the arrow-as-character rendered as a tofu box — so `.first` // is the summary's copy and `.last` the one in ROUTE below it. final route = tester.widget(find.text('Vidhya Kitchen').first); expect(headline.style!.fontSize, greaterThan(route.style!.fontSize!)); expect(find.byIcon(LucideIcons.arrowRight), findsOneWidget); }); testWidgets('a cancelled stop never heads itself "Delivered"', ( tester, ) async { ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, record(status: 'cancelled')); expect(find.text('Cancelled'), findsWidgets); // The *summary* is the outcome and it is Cancelled. `Delivered` appears // once and only once, as the open-ring rung for the step this record // never reached — see the group below. final summary = tester.widget(find.text('Cancelled').first); expect(summary.style!.fontSize, greaterThan(20)); expect(find.text('Delivered'), findsOneWidget); expect(find.text('Did not happen'), findsOneWidget); }); testWidgets('a skipped stop leads with the reason it was put down', ( tester, ) async { ServiceProfile.setActive(ServiceProfile.milkMan); await pump( tester, record(status: 'skipped', skipReason: 'Customer unreachable'), ); expect(find.text('Skipped'), findsWidgets); // The one thing on the record he cannot reconstruct for himself: he knows // who and where, and what he has forgotten by the afternoon is *why he // walked away*. expect(find.textContaining('Customer unreachable'), findsWidgets); }); }); group('the timeline', () { testWidgets('runs oldest first, named for the leg', (tester) async { ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, record()); // The door arrival is not a rung: it folds into the outcome as a // duration, because "he got there 5m before he handed it over" is a // subtraction, not an event worth its own line. expect(find.text('Arrived at customer'), findsNothing); expect(find.text('5m at the door'), findsOneWidget); expect(find.text('Out for delivery'), findsOneWidget); // ── Downwards, like every order-tracking screen ── // // Placed at the top, the outcome at the bottom, so the eye finishes on // what the record is about. The order is the whole reason this is a rail // and not a list — the rider takes the sequence off the geometry, not off // the clocks. `.last` is the timeline's rung; `.first` is the summary. final off = tester.getRect(find.text('Out for delivery')); final head = tester.getRect(find.text('Delivered').last); expect(off.top, lessThan(head.top)); // One rail, so every title starts at the same gutter. expect(head.left, closeTo(off.left, 0.5)); }); testWidgets('a collection is named for its own leg', (tester) async { await pump(tester, record(type: 'pickup', status: 'picked')); expect(find.text('Picked up'), findsWidgets); // On a collection `arrivedat` IS the counter, so it fills the same rung // the kitchen event fills on a round. expect(find.text('Arrived'), findsOneWidget); expect(find.text('Set off'), findsOneWidget); // Words from the other leg must not leak across. expect(find.text('Out for delivery'), findsNothing); }); testWidgets('an unstamped rung is absent, not a placeholder', ( tester, ) async { // A stop closed in bulk from Home never opened the map, so nothing // recorded a departure or an arrival. One event happened; one rung draws. await pump( tester, record( type: 'pickup', status: 'picked', started: false, arrived: false, ), ); expect(find.text('Set off'), findsNothing); expect(find.text('Arrived'), findsNothing); // None of the shapes a placeholder would take. A record that invents a // plausible step is worse than one with a hole in it, because the hole // can be investigated and the invention cannot be caught. for (final invented in const [ 'Pending', 'Not recorded', '--:--', '12:00 AM', ]) { expect( find.text(invented), findsNothing, reason: '"$invented" is a lie', ); } }); testWidgets('the middle rung drops on its own', (tester) async { await pump( tester, record(type: 'pickup', status: 'picked', arrived: false), ); expect(find.text('Set off'), findsOneWidget); expect(find.text('Arrived'), findsNothing); }); }); group('the full history', () { testWidgets('every moment the app recorded gets a rung', (tester) async { ServiceProfile.setActive(ServiceProfile.milkMan); final base = DateTime(2026, 8, 20, 9, 10); await pump(tester, { ...record(), // What the booking carries: when the order was created. 'starttime': base .subtract(const Duration(minutes: 25)) .toIso8601String(), // What the rider's own screens stamped as they happened. OrderEvent.assigned: base .subtract(const Duration(minutes: 9)) .toIso8601String(), OrderEvent.accepted: base.toIso8601String(), OrderEvent.arrivedAtPickup: base .add(const Duration(minutes: 18)) .toIso8601String(), OrderEvent.pickedUp: base .add(const Duration(minutes: 25)) .toIso8601String(), OrderEvent.outForDelivery: base .add(const Duration(minutes: 65)) .toIso8601String(), }); for (final rung in const [ 'Assigned', 'Accepted', 'Arrived', 'Picked up', 'Out for delivery', 'Delivered', ]) { expect(find.text(rung), findsWidgets, reason: '$rung is missing'); } // ── A glyph per event, not one tick eight times ── // // A rail of identical marks says only "these all happened", which the // rail says by being solid. A rider scanning for the moment he took the // bag is looking for a shape. for (final glyph in const [ LucideIcons.mapPinned, LucideIcons.squareCheckBig, LucideIcons.store, LucideIcons.package, LucideIcons.bike, ]) { expect(find.byIcon(glyph), findsWidgets, reason: '$glyph is missing'); } // Oldest at the top, whatever order the stamps were assembled in. final tops = [ for (final rung in const [ 'Assigned', 'Accepted', 'Arrived', 'Picked up', 'Out for delivery', 'Delivered', ]) tester.getRect(find.text(rung).last).top, ]; for (var i = 1; i < tops.length; i++) { expect( tops[i], greaterThan(tops[i - 1]), reason: 'rung $i is out of order', ); } }); testWidgets('a record from before the ledger keeps its three rungs', ( tester, ) async { // Everything already on a rider's device was written before these stamps // existed. It must degrade to what it has, not to a page of placeholders. ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, record()); expect(find.text('Delivered'), findsNWidgets(2)); // The door arrival is not a rung: it folds into the outcome as a // duration, because "he got there 5m before he handed it over" is a // subtraction, not an event worth its own line. expect(find.text('Arrived at customer'), findsNothing); expect(find.text('5m at the door'), findsOneWidget); expect(find.text('Out for delivery'), findsOneWidget); for (final absent in const [ 'Order placed', 'Order accepted', 'Arrived at pickup', 'Picked up', ]) { expect(find.text(absent), findsNothing, reason: '$absent was invented'); } }); }); group('the step that never happened', () { testWidgets('a skipped stop shows the delivery it did not make', ( tester, ) async { ServiceProfile.setActive(ServiceProfile.milkMan); await pump( tester, record(status: 'skipped', skipReason: 'Customer unreachable'), ); // The one rung on this page allowed to exist without a timestamp: a // skipped stop has a delivery in its story that did not occur, and saying // so is the point of the record. It is an open ring, not a filled disc, // so it cannot read as a step that is merely pending. expect(find.text('Delivered'), findsOneWidget); expect(find.text('Not completed — return visit owed'), findsOneWidget); }); testWidgets('a completed stop has nothing left unreached', (tester) async { ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, record()); // Twice — the summary and the timeline's last rung — and never a third // time as a step still to come. expect(find.text('Delivered'), findsNWidgets(2)); expect(find.text('Not completed — return visit owed'), findsNothing); expect(find.text('Did not happen'), findsNothing); }); }); group('route', () { testWidgets('a round draws both ends; a parcel draws one', (tester) async { ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, record()); // ── ROUTE is gone; its two addresses moved onto the journey ── // // Tracking said *when*, Route said *where*, and the rider joined them // himself. Each place is now written onto the rung that happened there. expect(find.text('ROUTE'), findsNothing); expect(find.text('JOURNEY'), findsOneWidget); // Once now, not twice. The hero's quiet `from → to` line names both // ends; the journey names the place on the rung that happened there, // with its address under it. A second ROUTE section printing the same // two names again was the duplication this merge removed. expect(find.text('Vidhya Kitchen'), findsOneWidget); expect( find.text('78, Cross Cut Road, Gandhipuram'), findsOneWidget, reason: 'the address hangs on the rung that happened at it. With no ' 'drop address on the booking the one address it has belongs to ' 'the door — the fallback ROUTE encoded and this inherited.', ); // A parcel booking is a first-mile collection *from* the customer, and // where it goes next is the hub's problem — there is no second name to // print, so there is no route to draw. ServiceProfile.setActive(ServiceProfile.parcel); await pump(tester, record(type: 'pickup', status: 'picked')); expect(find.text('ROUTE'), findsNothing); expect(find.text('JOURNEY'), findsOneWidget); }); }); group('the measurements', () { testWidgets('only what was measured is shown', (tester) async { await pump( tester, record( type: 'pickup', status: 'picked', compliance: const {'ontime': true, 'actualkm': 4.6, 'plannedkm': 4.1}, ), ); await unfold(tester); // ── The labelled stats strip is gone ── // // DISTANCE / TOTAL TIME / ORDERS was three captioned cells in a tinted // box — a dashboard widget, and a dashboard is for comparing many things // across records. There is one stop on this page and it is finished. The // figures still earn a place; they earn a line. expect(find.text('DISTANCE'), findsNothing); expect(find.text('TOTAL TIME'), findsNothing); // The distance now reads once in that line, and once in the folded row // that names it. expect(find.text('4.6 km'), findsOneWidget); expect(find.textContaining('4.6 km'), findsWidgets); expect(find.text('4.1 km'), findsOneWidget); final verdict = tester.widget(find.text('Yes')); expect(verdict.style!.color, ColorConstants.acceptGreen); // A record that invents a plausible number cannot be caught. for (final invented in const ['0 km', '0.0 km', '0m', '₹0']) { expect( find.text(invented), findsNothing, reason: '"$invented" is a lie', ); } }); testWidgets('an unmeasured stop says so out loud', (tester) async { await pump(tester, record(type: 'pickup', status: 'picked')); await unfold(tester); // Said out loud, not implied by a missing row: a dropped row would be // indistinguishable from one that simply had not been scrolled to. expect(find.text('Not measured'), findsOneWidget); expect(find.text('Distance'), findsNothing); }); }); group('order and payment', () { testWidgets('the reference is there and copyable', (tester) async { await pump(tester, record(type: 'pickup', status: 'picked')); await unfold(tester); expect(find.text('#DM101'), findsOneWidget); expect(find.byIcon(LucideIcons.copy), findsOneWidget); }); testWidgets('a round shows no money and no shipment desk', (tester) async { ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, record(cash: 340)); await unfold(tester); // Subscribers pay the client monthly; `collectsCash` is false, so not // one collection row is drawn rather than printing "nothing to collect". // // The **group** still exists, because the stop payout lives in it — // earnings folded into Payment rather than standing as a second group // about money. What must be absent is any figure. expect(find.text('Due at this stop'), findsNothing); expect(find.text('Collected'), findsNothing); expect(find.textContaining('₹'), findsNothing); // ── No fabricated payout ── // // `deliver` computes `ridercharges` server-side and returns nothing, and // `/miler/earnings` answers for a period rather than a stop. A number // here would be pay derived from a rate the app does not hold — the one // invention on this screen a rider would act on and be wrong about. expect(find.text('Stop payout'), findsOneWidget); expect(find.text('See Account'), findsOneWidget); // Weight and parcel counts belong to the shipment desk, which is a // capability this line does not run. expect(find.text('Weight'), findsNothing); expect(find.text('Parcels handed over'), findsNothing); }); testWidgets('a parcel stop that collected shows what it took', ( tester, ) async { await pump( tester, record(type: 'pickup', status: 'picked', cash: 340, collected: 340), ); await unfold(tester); expect(find.text('Payment'), findsWidgets); // Due, collected, and again under EARNINGS — the cash that changed hands // is the one earnings figure this stop really has. expect(find.text('₹340'), findsNWidgets(3)); // Earnings folded into Payment — one group about money, not two. expect(find.text('Payment'), findsWidgets); expect(find.text('Cash collected'), findsOneWidget); expect(find.text('Weight'), findsOneWidget); expect(find.text('2.4 kg'), findsOneWidget); }); testWidgets('a prepaid parcel stop draws no payment section', ( tester, ) async { await pump(tester, record(type: 'pickup', status: 'picked')); await unfold(tester); // Nothing was due and nothing was taken, so not one collection row is // drawn — a section spent saying a section does not apply is a section // too many. The group itself survives on the payout row alone. expect(find.text('Due at this stop'), findsNothing); expect(find.text('Collected'), findsNothing); }); }); // ══════════════════════════════════════════════════════════════════════ // ROUTE — each end owns its own address // ══════════════════════════════════════════════════════════════════════ // // `_route` read `pickupaddress` **only** and hung it under the drop. On a // parcel booking that is right by construction — one address, and the // collection point IS the customer. On a milk run the payload carries both, // so the record printed the kitchen's street under the customer's name: the // most checkable fact on the one screen that exists to be checked. group('route', () { tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); testWidgets('a round hangs each address on its own end', (tester) async { ServiceProfile.setActive(ServiceProfile.milkMan); // Both ends need a rung to hang on: with no collection stamp the // journey has nothing that happened at the counter, and the kitchen's // address would have nowhere to go. That is the correct behaviour — a // place is only drawn on a moment the record actually holds — so the // fixture supplies the moment. // The fixture's own clock, not the wall clock: a rung stamped after the // record closed is dropped on purpose (a timeline that contradicts // itself is worse than a short one), and `record()` completes at a fixed // past moment. final at = now.subtract(const Duration(minutes: 40)); await pump(tester, { ...record(), 'pickupaddress': '4 Cross, RS Puram, Coimbatore 641002', 'dropaddress': '12, SNS Colony, Peelamedu, Coimbatore 641004', 'arrived_at_pickup': at.toIso8601String(), 'picked_up': at.add(const Duration(minutes: 4)).toIso8601String(), }); // Both addresses now sit on the journey, each on the rung that happened // at it — the counter on the collection, the door on the outcome — so // this reads the ladder rather than a separate ROUTE section. final kitchen = tester.getRect( find.text('4 Cross, RS Puram, Coimbatore 641002'), ); final door = tester.getRect( find.text('12, SNS Colony, Peelamedu, Coimbatore 641004'), ); expect( kitchen.top, lessThan(door.top), reason: 'the kitchen is the first end of the journey', ); expect( tester.getRect(find.text('Joe Mathew').last).top, lessThan(door.top), reason: "the customer's door hangs under the customer, not the kitchen", ); }); testWidgets('one address still belongs to the door', (tester) async { // The fallback, unchanged. A payload with no separate drop must keep // hanging its one address exactly where it always hung. ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, record()); await unfold(tester); expect(find.text('78, Cross Cut Road, Gandhipuram'), findsOneWidget); }); testWidgets('the same street is never printed twice', (tester) async { ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, record()); await unfold(tester); expect(find.text('78, Cross Cut Road, Gandhipuram'), findsOneWidget); }); }); }