import 'dart:convert'; 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: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/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:miler/data/accepted_store.dart'; /// ───────────────────────────────────────────────────────────────────────── /// ACTIVITY, AS A DELIVERY HISTORY /// /// The page used to be a day's worth of rows headed by the customer's name — /// the one fact on a finished stop a rider cannot act on, and the one he is not /// scanning for. History is opened to answer "did that go through?", and the /// answer to that is a word. /// /// So the row leads with the outcome, identifies itself with the route /// underneath, and carries its measurements as one line of running text. Above /// the list: what this screen is, and which slice of it. These hold that shape. /// ───────────────────────────────────────────────────────────────────────── 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 done( String id, String who, { int minsAgo = 20, String status = 'delivered', double cash = 0, }) { final at = now.subtract(Duration(minutes: minsAgo)); return { 'orderid': id, 'type': 'delivery', 'pickupcustomer': who, 'sourcename': 'Vidhya Kitchen', 'pickupaddress': '78, Cross Cut Road, Coimbatore', 'orderstatus': status, 'completedday': day(now), 'completedat': at.toIso8601String(), 'startedat': at.subtract(const Duration(minutes: 28)).toIso8601String(), 'compliance': const {'ontime': true, 'actualkm': 3.2, 'plannedkm': 3.0}, if (cash > 0) 'collectionamt': '$cash', 'proof': {'collected': 2, if (cash > 0) 'amountcollected': cash}, }; } Map parked(String id, String who, String reason) => { 'orderid': id, 'type': 'delivery', 'pickupcustomer': who, 'sourcename': 'Vidhya Kitchen', 'pickupaddress': '19, Thadagam Road, Coimbatore', 'orderstatus': 'skipped', 'skipreason': reason, 'skippedday': day(now), 'skippedat': now.subtract(const Duration(minutes: 90)).toIso8601String(), }; Future pump( WidgetTester tester, { List> completed = const [], List> skipped = const [], bool settle = true, }) async { SharedPreferences.setMockInitialValues({'userId': 1}); await debugSeedStore('completed_bookings', jsonEncode(completed)); await debugSeedStore('skipped_bookings', jsonEncode(skipped)); // A phone's shape, and taller than one. The rows are cards now, so a // default 800×600 harness cuts the list off part-way and a lazy `ListView` // never builds what is past the fold — every assertion about a third row // would then pass or fail on viewport height rather than on the widget. tester.view.physicalSize = const Size(390 * 3, 1400 * 3); tester.view.devicePixelRatio = 3; 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 { await tester.pump(); } } /// Taps a filter chip, scrolling the bar to it first. /// /// The bar scrolls on purpose — four chips do not fit a narrow phone at large /// text scales, and a bar that reflowed to two lines would move the list under /// the rider's thumb. A chip past the fold is built but outside the viewport, /// and a tap at its centre lands on whatever is painted there instead. Future selectFilter(WidgetTester tester, String label) async { await tester.scrollUntilVisible( find.text(label), 80, scrollable: find.byType(Scrollable).first, ); await tester.pumpAndSettle(); await tester.tap(find.text(label)); await tester.pumpAndSettle(); } tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); group('the chrome', () { testWidgets('the header answers how the shift is going', (tester) async { await pump( tester, completed: [done('A1', 'Joe Mathew'), done('A2', 'Sarah')], ); // The strapline said "Your recent delivery activity" under an app bar // reading **Activity** — one line repeating the other. What a rider // opens this screen for between stops is how his day is going, so that // is what the space says now. expect(find.text('Your recent delivery activity'), findsNothing); // The header is a figure now: the completed count as a numeral with // "delivered today" as its caption — the same treatment Home's head // uses, and the fixture's two finished stops are the number it shows. expect(find.text('2'), findsOneWidget); expect(find.textContaining('today'), findsOneWidget); for (final f in const ['All', 'Active', 'Completed', 'Cancelled']) { expect(find.text(f), findsOneWidget, reason: '$f is missing'); } }); testWidgets('the selected filter is a rule, not a chip', (tester) async { await pump(tester, completed: [done('A1', 'Joe Mathew')]); // ── This assertion moved down a level, deliberately ── // // It used to require exactly one *filled chip*, which was already the // quiet version: a bar where all four are outlined is four boxes // arguing about which one is on. But a filled pill is still a container, // and on a page whose brief is that containers recede it was the // loudest object above the list. // // What survives is the principle, one step further: the selected state // is carried by the brand colour on the word plus a 2pt rule under it, // and the unselected ones carry nothing at all. final selected = tester.widget(find.text('All')); expect(selected.style!.color, ColorConstants.primary); expect(selected.style!.fontWeight, FontWeight.w700); final other = tester.widget(find.text('Completed')); expect(other.style!.color, ColorConstants.secondaryText); // One rule, on the selected word only. final rules = tester .widgetList(find.byType(AnimatedContainer)) .map((c) => c.decoration) .whereType() .where((d) => d.color == ColorConstants.primary) .toList(); expect(rules.length, 1); }); testWidgets('there is no chrome to operate on an empty day', ( tester, ) async { await pump(tester); // Nothing to filter, so the empty state owns the screen rather than // sitting under a bar the rider cannot usefully press. expect(find.text('Your recent delivery activity'), findsNothing); expect(find.text('Completed'), findsNothing); expect(find.textContaining('Go to'), findsOneWidget); }); }); group('the row', () { testWidgets('leads with the outcome, in the outcome\'s colour', ( tester, ) async { ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, completed: [done('A1', 'Joe Mathew')]); // The outcome word goes quiet for the norm: the rail disc beside the // row already carries "delivered" in shape and colour, and a day of 25 // green words left no colour for the row that went wrong. The word only // wears the status ink when the status is the exception — pinned in // activity_row_test's parked-stop case. final word = tester.widget(find.textContaining('Delivered')); expect(word.style!.color, ColorConstants.secondaryText); // The rider knows a stop by who was at the door, so the name leads. final name = tester.widget(find.text('Joe Mathew')); expect(name.style!.fontWeight, FontWeight.w600); expect( name.style!.fontSize, greaterThan(word.style!.fontSize!), reason: 'the customer outranks the status word', ); // Where it came from is the supporting line under it — one line now, // both ends together, rather than the source as a separate fragment. expect(find.textContaining('Vidhya Kitchen'), findsOneWidget); // ── The booking reference is gone from the row ── // // It was a footer here, which is already quiet, but quiet is not the // question: it is a lookup key an office reads down a phone, and a // rider scanning his own shift should not be passing over database // identifiers to reach a name. It is on the record page, one tap away. expect(find.text('#A1'), findsNothing); // The measurements are running text, not columns and not chips. expect(find.text('3.2 km'), findsOneWidget); expect(find.text('28m'), findsOneWidget); // The address is detail for one stop; it belongs in the record. expect(find.text('78, Cross Cut Road, Coimbatore'), findsNothing); }); testWidgets('a collection with no source names one end only', ( tester, ) async { // 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, and inventing an arrow to nowhere would read as a route. await pump( tester, completed: [done('A1', 'Sundar Textiles', status: 'picked')], ); // The customer IS the collection point on this line, so the name leads // and there is no second end to name under it. expect(find.text('Sundar Textiles'), findsOneWidget); expect(find.byIcon(LucideIcons.store), findsNothing); }); }); group('the slices', () { testWidgets('Completed drops the parked work', (tester) async { await pump( tester, completed: [done('A1', 'Joe Mathew', status: 'picked')], skipped: [parked('A9', 'Sri Balaji Stores', 'Shutter closed')], ); expect(find.textContaining('Skipped'), findsOneWidget); await selectFilter(tester, 'Completed'); expect(find.textContaining('Skipped'), findsNothing); expect(find.textContaining('Picked up'), findsOneWidget); // The lifted band is All's alone: a list of nothing but parked work does // not need a heading saying so. expect(find.text('NEEDS A RETURN VISIT'), findsNothing); }); testWidgets('an empty slice says so about itself, with a way back', ( tester, ) async { await pump( tester, completed: [done('A1', 'Joe Mathew', status: 'picked')], ); await selectFilter(tester, 'Cancelled'); // A fact about the slice, never an apology — and never the day's own // empty state, which would tell the rider he had done nothing. // // `Show all` is the slice's own way out; the day's empty state offers // `Go to Bookings` instead. Asserting on the slice's copy is the // unambiguous half — both states carry *a* button, and which one is // showing is what this test is about. expect(find.text('Nothing was cancelled.'), findsOneWidget); expect(find.text('Show all'), findsOneWidget); await tester.tap(find.widgetWithText(MilerButton, 'Show all')); await tester.pumpAndSettle(); expect(find.textContaining('Picked up'), findsOneWidget); }); }); group('grouping — the rider’s trips, not the clock’s bands', () { /// A stop finished inside a given hour today, so the day-part rule places /// it in a known trip. See [DayPart]. Map atHour(String id, String who, int hour) { final at = DateTime(now.year, now.month, now.day, hour, 15); return { ...done(id, who), 'completedat': at.toIso8601String(), 'expected_pickup_time': at.toIso8601String(), }; } testWidgets('one run needs no accordion — it opens as itself', ( tester, ) async { await pump( tester, completed: [ atHour('A1', 'Joe Mathew', 9), atHour('A2', 'Green Leaf', 10), ], ); // A single header the rider must tap to see the only thing on the page // is a lock on an empty door. expect(find.text('Trip 1'), findsOneWidget); expect(find.text('Joe Mathew'), findsOneWidget); expect(find.text('Green Leaf'), findsOneWidget); expect(find.byIcon(LucideIcons.chevronDown), findsNothing); }); testWidgets('three runs become three headers, all closed', (tester) async { await pump( tester, completed: [ atHour('A1', 'Joe Mathew', 9), atHour('A2', 'Green Leaf', 14), atHour('A3', 'Sri Balaji', 19), ], ); expect(find.text('Trip 1'), findsOneWidget); expect(find.text('Trip 2'), findsOneWidget); expect(find.text('Trip 3'), findsOneWidget); // Collapsed: the summary answers "did that run go well" without opening. expect(find.text('Joe Mathew'), findsNothing); expect(find.byIcon(LucideIcons.chevronDown), findsNWidgets(3)); }); testWidgets('opening one trip closes the one already open', (tester) async { await pump( tester, completed: [ atHour('A1', 'Joe Mathew', 9), atHour('A2', 'Green Leaf', 14), ], ); await tester.tap(find.text('Trip 1')); await tester.pumpAndSettle(); expect(find.text('Joe Mathew'), findsOneWidget); expect(find.text('Green Leaf'), findsNothing); await tester.tap(find.text('Trip 2')); await tester.pumpAndSettle(); expect( find.text('Joe Mathew'), findsNothing, reason: 'three expanded bodies is the list the rider already could ' 'not read — one at a time is the whole point', ); expect(find.text('Green Leaf'), findsOneWidget); // Tapping the open one closes it again. await tester.tap(find.text('Trip 2')); await tester.pumpAndSettle(); expect(find.text('Green Leaf'), findsNothing); }); testWidgets('a skipped stop stays inside the trip it belonged to', ( tester, ) async { final skip = { ...parked('A9', 'Sri Balaji Stores', 'Shutter closed'), 'skippedat': DateTime( now.year, now.month, now.day, 9, 30, ).toIso8601String(), 'expected_pickup_time': DateTime( now.year, now.month, now.day, 9, 30, ).toIso8601String(), }; await pump( tester, completed: [atHour('A1', 'Joe Mathew', 9)], skipped: [skip], ); // One morning run holding both outcomes. The summary names them apart // without opening, and neither word is an API enum. expect(find.text('Trip 1'), findsOneWidget); expect(find.textContaining('skipped'), findsWidgets); expect(find.text('Sri Balaji Stores'), findsOneWidget); expect(find.text('Joe Mathew'), findsOneWidget); }); testWidgets('no date band, and never the word UNDATED', (tester) async { // A record whose timestamps will not parse is a payload defect, not a // fact about the rider's morning — and not something to print at him. await pump( tester, completed: [ {...done('A1', 'Joe Mathew'), 'completedat': 'not-a-timestamp'}, ], ); expect(find.text('UNDATED'), findsNothing); expect(find.text('Undated'), findsNothing); expect(find.text('TODAY'), findsNothing); expect( find.text('Joe Mathew'), findsOneWidget, reason: 'the record is kept, quietly — dropping it would lose a ' "rider's completed stop over a missing field", ); }); testWidgets('"Trip 2 had a problem", then "Arun is the one"', ( tester, ) async { // ── The whole brief, as one test ── // // At arm's length, in two seconds: which trip went wrong. Then, one tap // later: which stop is owed. If either answer needs reading rather than // glancing, the page has failed regardless of how it photographs. final skipAt = DateTime(now.year, now.month, now.day, 14, 40); await pump( tester, completed: [ atHour('A1', 'Joe Mathew', 9), atHour('A2', 'Sarah', 10), atHour('A3', 'Priya Nair', 14), atHour('A4', 'Meera', 19), ], skipped: [ { ...parked('A5', 'Arun', 'Customer unavailable'), 'skippedat': skipAt.toIso8601String(), 'expected_pickup_time': skipAt.toIso8601String(), }, ], ); // 1. The shift says something is owed, before any trip is opened. expect(find.textContaining('needs attention'), findsOneWidget); // 2. And exactly one trip admits to it — Trip 2, on its collapsed // header, without the rider opening anything. expect(find.text('Trip 1'), findsOneWidget); expect(find.text('Trip 2'), findsOneWidget); expect(find.text('Trip 3'), findsOneWidget); expect(find.textContaining('skipped'), findsOneWidget); expect(find.text('Arun'), findsNothing, reason: 'still collapsed'); // 3. Opening it names the stop and offers the one thing left to do. await tester.tap(find.text('Trip 2')); await tester.pumpAndSettle(); expect(find.text('Arun'), findsOneWidget); expect(find.text('Customer unavailable'), findsOneWidget); expect(find.text('Resume stop'), findsOneWidget); // 4. And the settled stops in the same trip carry no action at all. expect(find.text('Priya Nair'), findsOneWidget); expect( find.text('Resume stop'), findsOneWidget, reason: 'a completed stop has nothing left to do', ); }); testWidgets('a completed row carries no booking reference', (tester) async { // The reference is a lookup key for an office. It lives on the record // page; a rider scanning his shift should not be reading past database // identifiers to find a name. await pump( tester, completed: [atHour('DM-BK-501CB551', 'Joe Mathew', 9)], ); expect(find.text('Joe Mathew'), findsOneWidget); expect(find.textContaining('DM-BK-501CB551'), findsNothing); }); testWidgets('no internal line or type name reaches the page', ( tester, ) async { await pump(tester, completed: [atHour('A1', 'Joe Mathew', 9)]); for (final leak in const ['MILK', 'Milk', 'Milk Man', 'milkMan']) { expect(find.text(leak), findsNothing, reason: leak); } }); }); group('states', () { testWidgets('the skeleton is drawn, not just laid out', (tester) async { await pump(tester, settle: false); // `SkeletonBone` paints in `pureSurface` — the page's own colour — and is // visible only inside the shimmer that recolours its subtree. Without the // wrapper this state rendered as a blank white screen, which is // indistinguishable from a load that has hung. expect(find.byType(MilerShimmer), findsOneWidget); expect( find.descendant( of: find.byType(MilerShimmer), matching: find.byType(SkeletonBone), ), findsWidgets, ); }); testWidgets('the empty state is artwork and one way out', (tester) async { await pump(tester); // ── The words went; the picture carries it ── // // A glyph, a headline and a sentence under it were three statements of // one fact — nothing finished yet — on a screen whose emptiness already // says so. Product's call is that the artwork says it alone. expect(find.text('No activity yet'), findsNothing); expect( find.text('Stops you finish today will appear here.'), findsNothing, ); expect(find.byIcon(LucideIcons.history), findsNothing); // What remains is the one thing here the rider can act on. // // The artwork itself is asserted by the reference shot rather than by a // finder: `Image.asset` resolves against a bundle the test binding does // not carry, so a finder for it would be pinning the harness rather than // the screen. expect(find.textContaining('Go to'), findsOneWidget); }); }); }