import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.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/service_profile.dart'; import 'package:miler/views/Dashboard/activity/activity_page.dart'; import 'package:miler/views/Dashboard/activity/delivery_details_page.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:miler/data/accepted_store.dart'; /// ── What a finished row says, and what it stopped saying ── /// /// A finished stop used to carry, under its address, a filled state tag, a /// dotted type, a copyable reference and three 17pt figures with a coloured /// verdict under each — six marks per row on a tab whose job is to be scanned. /// A day's work was taller than the day's work list on the Deliveries tab. /// /// It is three lines now: who and when, where, and one verdict line that says /// what kind of stop it was and how it went. The record — every stamp, every /// figure, the reference, the money — is one tap under it. /// /// [activity_list_test.dart] holds the older half of this contract: that a row /// is not a card, that the figure columns line up across rows rather than only /// within one, that the type is a word rather than a filled tag. This file /// holds what the redesign added: what is on the collapsed row, that a missing /// measurement is said rather than invented, and that money is gated on the /// capability rather than on the line of work. void main() { String day(DateTime t) => '${t.year}-${t.month.toString().padLeft(2, '0')}-' '${t.day.toString().padLeft(2, '0')}'; final now = DateTime.now(); Map stop( String id, String name, { required String status, String type = 'delivery', int minsAgo = 20, double cash = 0, double? collected, Map? compliance, }) { final at = now.subtract(Duration(minutes: minsAgo)); return { 'orderid': id, 'tripid': 'T1', 'type': type, 'pickupcustomer': name, 'pickupaddress': '12, SNS Colony, Coimbatore', 'orderstatus': status, 'completedday': day(now), 'completedat': at.toIso8601String(), 'startedat': at.subtract(const Duration(minutes: 14)).toIso8601String(), 'arrivedat': at.subtract(const Duration(minutes: 3)).toIso8601String(), if (cash > 0) 'collectionamt': '$cash', if (compliance != null) 'compliance': compliance, 'proof': { if (collected != null) 'amountcollected': collected, 'weight': '2.4', 'collected': 2, }, }; } Map parked(String id, String name, String reason) => { 'orderid': id, 'tripid': 'T1', 'type': 'delivery', 'pickupcustomer': name, 'pickupaddress': '19, Thadagam Road, Coimbatore', 'orderstatus': 'skipped', 'skipreason': reason, 'skippedday': day(now), 'skippedat': now.subtract(const Duration(minutes: 40)).toIso8601String(), }; Future pump( WidgetTester tester, { List> done = const [], List> skipped = const [], ServiceProfile profile = ServiceProfile.parcel, bool settle = true, }) async { ServiceProfile.setActive(profile); SharedPreferences.setMockInitialValues({'userId': 1}); await debugSeedStore('completed_bookings', jsonEncode(done)); await debugSeedStore('skipped_bookings', jsonEncode(skipped)); tester.view.physicalSize = const Size(390 * 3, 1600 * 3); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); await tester.pumpWidget( ScreenUtilInit( designSize: const Size(390, 844), builder: (_, _) => const MaterialApp(home: ActivityPage()), ), ); if (settle) { await tester.pumpAndSettle(const Duration(seconds: 2)); } else { // The empty state runs a looping Lottie, so nothing ever settles. await tester.pump(); for (var i = 0; i < 20; i++) { await tester.pump(const Duration(milliseconds: 80)); } } } tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); final onTime = {'ontime': true, 'actualkm': 3.4, 'plannedkm': 3.1}; final late7 = { 'ontime': false, 'latebyseconds': 420, 'actualkm': 6.2, 'plannedkm': 3.0, }; // ── History is scanned, not read ── // // The collapsed row carried a name, a two-line address and a verdict sentence // (`Delivery · On time · +3.2 km over · ₹340`) — four to six lines per stop, // on a page whose question is "what did I do today". A rider looking for one // drop read forty lines to find it. // // What is left collapsed: who, when, how much, and a mark for anything that // did not go to plan. The rest did not disappear — it is in the row's own // detail panel, one tap away, which is where a rider wants it for ONE stop. /// Opens a history row. The detail panel is built only while the row is open /// — `AnimatedSize` around one child rather than a crossfade laying out both, /// which is what removed the overflow stripe on collapse. /// Opens a stop's record, and unfolds the groups the measurements live in. /// /// The record page ranks its facts now: outcome, journey and proof lead, and /// the ledger — distance, ETA, payment, reference — sits behind three folded /// rows, tapped only when somebody in an office asks. The facts are /// unchanged; reaching them costs a tap, so this takes it. Future open(WidgetTester tester, String name) async { await tester.tap(find.text(name)); await tester.pumpAndSettle(); for (final group in const [ 'Delivery details', 'Payment', 'Order details', ]) { // Inside its own control: the app bar's title is also "Delivery // details", and an ambiguous finder makes `tap` throw. final row = find.descendant( of: find.byType(InkWell), matching: find.text(group), ); if (row.evaluate().isEmpty) continue; await tester.tap(row.first); await tester.pumpAndSettle(); } } testWidgets('the collapsed row is a name, a clock and nothing else', ( tester, ) async { await pump( tester, done: [stop('A1', 'Joe Mathew', status: 'picked', compliance: onTime)], ); expect(find.text('Joe Mathew'), findsOneWidget); // The address is detail for one stop, not a header for forty. expect(find.text('12, SNS Colony, Coimbatore'), findsNothing); // `titleLabel` led the verdict sentence and went with it. expect(find.text('Delivery'), findsNothing); // A norm stated on every row separates nothing. expect(find.text('On time'), findsNothing); // The filled state tag is still gone. expect(find.byType(OrderStatusTag), findsNothing); }); testWidgets('an exception is coloured; the norm is not', (tester) async { await pump( tester, done: [ stop('A1', 'Joe Mathew', status: 'picked', compliance: onTime), stop( 'A2', 'Green Leaf', status: 'picked', minsAgo: 60, compliance: late7, ), ], ); // The principle is unchanged and is now absolute: the norm says NOTHING on // the row, and the exception is the only thing that gets a word — in the // warning colour, in a capsule, beside the stop it happened to. expect(find.text('On time'), findsNothing); expect( tester.widget(find.text('Late 7m')).style!.color, ColorConstants.warning, ); // Being on route is the norm and is not said at all. Riding past it is — // in the detail panel now, where the rest of the measurements live. expect(find.text('On route'), findsNothing); await open(tester, 'Green Leaf'); expect(find.textContaining('3.2 km'), findsWidgets); }); testWidgets('a stop nothing was measured on says so', (tester) async { await pump(tester, done: [stop('A1', 'Anitha Ramesh', status: 'picked')]); // Said out loud, not implied by a missing row: `_detailRow` drops a null // value, so silence here would be indistinguishable from a row that simply // had not been rendered. await open(tester, 'Anitha Ramesh'); expect(find.text('Not measured'), findsWidgets); // A record that invents a plausible number is worse than one with a hole // in it, because the hole can be investigated and the invention cannot be // caught. None of these may appear in place of a missing measurement. for (final invented in const ['0m', '0 min', '0 km', '0.0 km', '₹0']) { expect(find.text(invented), findsNothing, reason: '"$invented" is a lie'); } }); // ── The record is a page, not an accordion ── // // It used to open under the row. An open record is several screens tall, so // the row below it left the viewport — and left what a lazy `ListView` had // built — and the list got harder to scan in exact proportion to how much it // was used. Its own assertions moved with it to // `test/delivery_details_test.dart`; what stays here is that the row is the // way in and nothing more. testWidgets('tapping a row opens the record on its own page', (tester) async { await pump( tester, done: [stop('A1', 'Joe Mathew', status: 'picked', compliance: onTime)], ); // Nothing of the record is on the list, open or closed. expect(find.text('ETA kept'), findsNothing); expect(find.byType(DeliveryDetailsPage), findsNothing); await tester.tap(find.text('Joe Mathew')); await tester.pumpAndSettle(); expect(find.byType(DeliveryDetailsPage), findsOneWidget); // Twice: the app bar's title, and the first folded group under the // journey. Both are legitimately called the same thing. expect(find.text('Delivery details'), findsWidgets); // And the bar's own back control leaves the rider where he was in the day. // `pageBack()` hunts for a Material/Cupertino back button; `MilerAppBar` // draws its own, so the test presses what the rider presses. await tester.tap(find.byIcon(LucideIcons.arrowLeft).first); await tester.pumpAndSettle(); expect(find.byType(DeliveryDetailsPage), findsNothing); expect(find.text('Joe Mathew'), findsOneWidget); }); testWidgets('a meal run has no money anywhere on the page', (tester) async { await pump( tester, profile: ServiceProfile.milkMan, done: [ // Cash on the payload, which a meal tenant must still never show: // `collectsCash` is the one gate, and it is false here. stop( 'M1', 'Priya Nair', status: 'delivered', cash: 220, compliance: onTime, ), ], ); await tester.tap(find.text('Priya Nair')); await tester.pumpAndSettle(); expect(find.text('COLLECTED'), findsNothing); expect(find.text('PAYMENT'), findsNothing); expect(find.textContaining('₹'), findsNothing); // The shipment-desk fields and the record's own figures are the detail // page's business — see `test/delivery_details_test.dart`. expect(find.text('Weight'), findsNothing); expect(find.text('Parcels handed over'), findsNothing); }); testWidgets('an empty day is still something you can pull on', ( tester, ) async { await pump(tester, settle: false); // ── The empty state is a caption, not a screen ── // // It was `nothing_finished_yet.png` at 300pt wide on a 390pt phone — a // picture larger than anything this page draws when it *does* have // something to say, standing in for the absence of six short rows. One // glyph, one headline, one line and the way out. expect( find.byWidgetPredicate( (w) => w is Image && w.image is AssetImage && (w.image as AssetImage).assetName == 'assets/images/nothing_finished_yet.png', ), findsNothing, reason: 'the oversized artwork must not come back', ); // The empty state is the artwork plus the one control — see // `activity_page_test`'s "artwork and one way out". expect(find.textContaining('Go to'), findsOneWidget); expect(find.textContaining('Go to'), findsOneWidget); // Pull-to-refresh is the rider's only way out of an empty day, and it dies // the moment the empty state stops being scrollable — which is what a // `Center` in place of this `ListView` would do. final list = tester.widget( find.byKey(const ValueKey('activity-empty')), ); expect(list.physics, isA()); await tester.fling( find.byKey(const ValueKey('activity-empty')), const Offset(0, 320), 800, ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(find.byType(RefreshProgressIndicator), findsOneWidget); for (var i = 0; i < 30; i++) { await tester.pump(const Duration(milliseconds: 80)); } }); testWidgets('a parked stop carries its reason and exactly one control', ( tester, ) async { await pump( tester, done: [stop('A1', 'Joe Mathew', status: 'picked', compliance: onTime)], skipped: [ parked('A9', 'Sri Balaji Stores', 'Customer unreachable'), parked('A8', 'Kumaran Silks', 'Shutter closed'), ], ); // ── The lifted band is gone; the trip is the grouping now ── // // Parked work used to be pulled out of date order and led with, because in // a single newest-first list a morning skip sank below everything finished // since. The page is grouped by the rider's trips instead, so a skip sits // in the run it happened on — which is where he looks for it — and the // trip's own header counts it so a collapsed run still says one is owed. // // What must not change is the row itself: its reason, and one control. expect(find.text('NEED A RETURN VISIT'), findsNothing); // The reason is the one thing on the row he cannot reconstruct for // himself, and it is read in amber at the size the address is — not a // 10.5sp filled pill. expect(find.textContaining('Skipped'), findsNWidgets(2)); final reason = tester.widget(find.text('Customer unreachable')); expect(reason.style!.color, ColorConstants.warning); expect(reason.style!.fontSize, greaterThanOrEqualTo(12.5)); // ── One control per parked row, and it is compact now ── // // Still exactly one per parked row and none on a finished one: a row // offering a second action makes the rider choose before he can act, and // there is nothing else to do with a stop he has put down. // // What changed is its weight. A full-width filled button on every parked // row made the exception look like the rule on a page that is otherwise // pure history — so it is a compact inline control, at a real tap target, // with the same in-flight guard the button had. expect(find.text('Resume this stop'), findsNothing); expect(find.text('Resume stop'), findsNWidgets(2)); expect( find.byType(MilerButton), findsNothing, reason: 'a history list is not a page of actions', ); }); }