Files
doormile_milderapp/test/activity_row_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

400 lines
15 KiB
Dart

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<String, dynamic> stop(
String id,
String name, {
required String status,
String type = 'delivery',
int minsAgo = 20,
double cash = 0,
double? collected,
Map<String, dynamic>? 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<String, dynamic> 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<void> pump(
WidgetTester tester, {
List<Map<String, dynamic>> done = const [],
List<Map<String, dynamic>> 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<void> 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<Text>(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<ListView>(
find.byKey(const ValueKey('activity-empty')),
);
expect(list.physics, isA<AlwaysScrollableScrollPhysics>());
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<Text>(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',
);
});
}