Files
doormile_milderapp/test/activity_page_test.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

544 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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 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<Text>(find.text('All'));
expect(selected.style!.color, ColorConstants.primary);
expect(selected.style!.fontWeight, FontWeight.w700);
final other = tester.widget<Text>(find.text('Completed'));
expect(other.style!.color, ColorConstants.secondaryText);
// One rule, on the selected word only.
final rules = tester
.widgetList<AnimatedContainer>(find.byType(AnimatedContainer))
.map((c) => c.decoration)
.whereType<BoxDecoration>()
.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<Text>(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<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.
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<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);
});
});
}