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>
This commit is contained in:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

View File

@@ -0,0 +1,543 @@
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);
});
});
}