570 lines
22 KiB
Dart
570 lines
22 KiB
Dart
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/constants/narrative.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<String, dynamic> 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<String, dynamic> 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<void> pump(
|
||
WidgetTester tester, {
|
||
List<Map<String, dynamic>> completed = const [],
|
||
List<Map<String, dynamic>> 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<void> 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 two figures now ──
|
||
//
|
||
// A count and a distance, given equal halves of the card with a hairline
|
||
// between: what the shift produced and what it cost. `today` came off
|
||
// both captions — the card is the day, and saying so twice inside it was
|
||
// the word doing no work.
|
||
expect(find.text('2'), findsOneWidget);
|
||
// The caption is the line's own completion verb — `picked up` on the
|
||
// parcel profile the fixture runs, `delivered` on a meal round — so the
|
||
// assertion is on the shape rather than on one line's word.
|
||
expect(
|
||
find.text(ServiceProfile.active.completionVerb.toLowerCase()),
|
||
findsOneWidget,
|
||
);
|
||
|
||
for (final f in const ['All', 'Active', 'Completed', 'Cancelled']) {
|
||
expect(find.text(f), findsOneWidget, reason: '$f is missing');
|
||
}
|
||
});
|
||
|
||
testWidgets('the selected filter is one travelling pill', (tester) async {
|
||
await pump(tester, completed: [done('A1', 'Joe Mathew')]);
|
||
|
||
// ── This assertion has moved three times, and the principle held ──
|
||
//
|
||
// It began as "exactly one *filled chip*", against a bar where all four
|
||
// were outlined — four boxes arguing about which one is on. It became "a
|
||
// rule, not a chip" when the page's brief was that containers recede.
|
||
// Then a segmented control, with the selected state a physical object
|
||
// sliding along a track.
|
||
//
|
||
// The track is gone: it was a container drawn around a control that
|
||
// already marks its own selection, and its equal slots forced every label
|
||
// to the width of the longest. Four separate pills now, and the selected
|
||
// one is filled brand with white type.
|
||
//
|
||
// The principle is the same one, stated a fourth time — **one** mark of
|
||
// selection on the bar, never four.
|
||
final selected = tester.widget<Text>(find.text('All'));
|
||
expect(selected.style!.color, Colors.white);
|
||
expect(selected.style!.fontWeight, FontWeight.w700);
|
||
|
||
final other = tester.widget<Text>(find.text('Completed'));
|
||
expect(other.style!.color, Narrative.inkMuted);
|
||
expect(other.style!.fontWeight, FontWeight.w600);
|
||
|
||
// Exactly one filled brand pill in the bar.
|
||
final filled = tester
|
||
.widgetList<AnimatedContainer>(find.byType(AnimatedContainer))
|
||
.map((c) => c.decoration)
|
||
.whereType<BoxDecoration>()
|
||
.where((d) => d.color == Narrative.burgundy)
|
||
.toList();
|
||
expect(filled, hasLength(1), reason: 'four selected filters is none');
|
||
});
|
||
|
||
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 is coloured again ──
|
||
//
|
||
// It went quiet when every row was drawn on the same white paper and
|
||
// colour was the only thing separating them. The rows are cards now, so
|
||
// the separation is structural, and the design brief makes emerald *the*
|
||
// delivered state — a status the eye can pick out without reading is the
|
||
// difference between scanning a day and reading one. The exception keeps
|
||
// its own ink, pinned in activity_row_test's parked-stop case.
|
||
final word = tester.widget<Text>(find.textContaining('Delivered'));
|
||
expect(word.style!.color, Narrative.emerald);
|
||
|
||
// The rider knows a stop by who was at the door, so the name leads.
|
||
// w600 — the brief's `title-lg`, which is what a card's own heading is
|
||
// set at. It briefly ran at w700 while the cards had no border and no
|
||
// lift to rank them; with both, the weight does not have to do that job
|
||
// as well.
|
||
final name = tester.widget<Text>(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.
|
||
//
|
||
// One match. The hero's half prints the bare figure with `km ridden` as
|
||
// its caption, so the only `3.2 km` on the page is this row's own.
|
||
expect(find.text('3.2 km'), findsOneWidget);
|
||
expect(find.text('km ridden'), 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<String, dynamic> 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);
|
||
});
|
||
});
|
||
}
|