production

This commit is contained in:
2026-08-28 11:13:15 +05:30
parent d7348e253f
commit 5723d373b2
162 changed files with 17924 additions and 7026 deletions

View File

@@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/views/Dashboard/activity/activity_page.dart';
import 'package:miler/views/Dashboard/profile/Profilepage.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/narrative.dart';
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
import 'package:miler/data/accepted_store.dart';
@@ -151,11 +152,30 @@ void main() {
// Activity were on `pureSurface`. Bookings hides its background under
// cards; Account is bare rows, so the whole tab read a shade dirtier.
//
// The paper moved: the scaffold's own background is the **brand** now, so
// it shows through the two rounded top corners of [MilerSheet], and the
// sheet paints the page. Both halves are contracts — a tab whose ground is
// white draws no corners at all, and a tab whose sheet is off-white is the
// original bug one layer down — so both are asserted.
// The paper moved: the scaffold's own background became the **brand**, so
// it could show through the two rounded top corners [MilerSheet] used to
// clip, and the sheet painted the page.
//
// ── Why the ground is no longer a fixed colour ──
//
// That arrangement is gone. The curve moved onto the bar, which paints the
// page colour behind its own corners (see [milerBrandSurface]), and
// [MilerSheet] clips at zero radius. A brand-red ground is therefore no
// longer load-bearing — it is only invisible for as long as the sheet
// covers every pixel of the body.
//
// On Activity it did not. The sheet was pushed down 30pt to make room for
// the floating figure card, and what showed in the gap was a full-width
// band of maroon directly under the app bar — bare down both gutters, and
// on a day with nothing finished (no card built at all) a solid red stripe
// across the screen. It read as a second, broken app bar.
//
// So the ground is asserted against the thing that actually matters: it
// must be either the brand — fine, where the sheet covers it whole — or
// the sheet's own colour, which is the only other way a band cannot
// appear. What stays banned is a *third* colour, which is how a tab ends
// up a shade dirtier than the rest. See also the Activity-specific test
// below, which pins the case that broke.
T? firstOf<T extends Widget>() {
final found = <T>[];
void visit(Element e) {
@@ -182,13 +202,16 @@ void main() {
);
await tester.pump();
expect(
firstOf<Scaffold>()?.backgroundColor,
ColorConstants.primary,
reason: 'the ground behind the sheet corners must be the brand',
);
final sheet = firstOf<MilerSheet>();
expect(sheet, isNotNull, reason: 'every tab sits in a MilerSheet');
final paper = sheet!.color ?? ColorConstants.pureSurface;
expect(
firstOf<Scaffold>()?.backgroundColor,
anyOf(ColorConstants.primary, paper),
reason:
'a ground that is neither the brand nor the page shows as a band',
);
// What the tabs must agree on is the *ladder*, not one flat paper:
// a sheet is either the working white (rows drawn straight on it —
// Activity) or the canvas with white panels standing on it (Home,
@@ -196,13 +219,69 @@ void main() {
// the warm off-white that made one tab read a shade dirtier than the
// rest.
expect(
sheet!.color ?? ColorConstants.pureSurface,
anyOf(ColorConstants.pureSurface, ColorConstants.daylightSurface),
reason: 'a sheet is a rung of the surface ladder, never an off-white',
paper,
anyOf(
ColorConstants.pureSurface,
ColorConstants.daylightSurface,
// Activity was rebuilt against the Narrative brief, which pairs a
// *lighter* canvas with cards that carry a border and a lift. Its
// ground is a rung of that ladder rather than this one — see
// [Narrative], and the note there about not mixing the two recipes.
Narrative.canvas,
),
reason: 'a sheet is a rung of a surface ladder, never an off-white',
);
}
});
testWidgets('Activity shows no brand band under the app bar', (
tester,
) async {
// The regression, pinned at the layer it happened on. Activity is the one
// tab that floats a card over the seam, so it is the one tab whose sheet
// was inset — and an inset sheet over a brand-red ground is a red stripe.
//
// Its ground is the page now, so there is nothing to leak whatever the
// sheet does, and the empty-day case — where the card is not built at all
// and the stripe was full width — is covered by the same fact.
T? firstOf<T extends Widget>() {
final found = <T>[];
void visit(Element e) {
final w = e.widget;
if (w is T) found.add(w);
e.visitChildren(visit);
}
WidgetsBinding.instance.rootElement!.visitChildren(visit);
return found.isEmpty ? null : found.first;
}
SharedPreferences.setMockInitialValues({'userId': 1});
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => const MaterialApp(home: ActivityPage()),
),
);
await tester.pump();
final ground = firstOf<Scaffold>()?.backgroundColor;
expect(
ground,
isNot(ColorConstants.primary),
reason: 'the 30pt maroon band under the bar was this colour',
);
expect(
ground,
firstOf<MilerSheet>()?.color ?? ColorConstants.pureSurface,
reason: 'ground and page must match, so no inset can expose a band',
);
});
// ── A row offers one gesture, and it is the whole row ──
//
// The card carried a footer band: a hairline, ~22pt of gaps, the booking

View File

@@ -9,6 +9,7 @@ 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';
@@ -127,46 +128,60 @@ void main() {
// 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.
// ── 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);
expect(find.textContaining('today'), 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 a rule, not a chip', (tester) async {
testWidgets('the selected filter is one travelling pill', (tester) async {
await pump(tester, completed: [done('A1', 'Joe Mathew')]);
// ── This assertion moved down a level, deliberately ──
// ── This assertion has moved three times, and the principle held ──
//
// 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.
// 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.
//
// 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.
// 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, ColorConstants.primary);
expect(selected.style!.color, Colors.white);
expect(selected.style!.fontWeight, FontWeight.w700);
final other = tester.widget<Text>(find.text('Completed'));
expect(other.style!.color, ColorConstants.secondaryText);
expect(other.style!.color, Narrative.inkMuted);
expect(other.style!.fontWeight, FontWeight.w600);
// One rule, on the selected word only.
final rules = tester
// 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 == ColorConstants.primary)
.where((d) => d.color == Narrative.burgundy)
.toList();
expect(rules.length, 1);
expect(filled, hasLength(1), reason: 'four selected filters is none');
});
testWidgets('there is no chrome to operate on an empty day', (
@@ -189,15 +204,22 @@ void main() {
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.
// ── 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, ColorConstants.secondaryText);
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(
@@ -219,7 +241,11 @@ void main() {
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.

View File

@@ -0,0 +1,373 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/service_day.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// ACTIVITY IS STRICTLY TODAY
///
/// The page is a shift log, not an archive, and the rule it rests on is one
/// predicate: [ServiceDay.belongsToToday]. These are the cases that broke it.
///
/// ── What actually went wrong ──
///
/// `ServiceDay.timeKeys` used to read `deliveredat`, `updatedat` and
/// `createdat` — three field names the backend does not send. A booking
/// finished yesterday comes back from `GET /miler/bookings` carrying
/// `pickedtime` and `updatedon`, neither of which was looked at, so
/// [ServiceDay.of] returned `''`, the predicate took its "undated rows are
/// kept" branch, and yesterday's work was admitted as today's.
///
/// Two things changed. The key list is now the one `happenedAt` sorts by — the
/// same fact read out of one list instead of two — and an undated row is now
/// **excluded and reported** rather than silently kept. The burden of proof
/// sits with the record: a row that cannot show it belongs to today does not
/// appear on a screen headed Today.
///
/// Nothing is deleted anywhere. This is a view filter, and [ServiceDay.of]
/// still answers for any caller that legitimately wants history.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
const today = '2026-08-27';
const yesterday = '2026-08-26';
/// A row as the backend sends it: no `completedday`/`skippedday` stamp,
/// because it has never been through either local store. This is the shape
/// that used to slip through.
Map<String, dynamic> apiRow({
required String id,
required String status,
required String at,
String key = 'pickedtime',
}) => <String, dynamic>{'orderid': id, 'orderstatus': status, key: at};
group('yesterday does not appear on today', () {
test('a Delivered booking finished yesterday is excluded', () {
final row = apiRow(
id: 'D-1',
status: 'delivered',
// The false trailing `Z` the backend really sends — IST wall-clock in
// a naive Postgres column, marshalled with a zone marker it never had.
at: '2026-08-26T14:20:00Z',
);
expect(stopStatusOf(row).isDelivered, isTrue);
expect(ServiceDay.of(row), yesterday);
expect(ServiceDay.belongsToToday(row, now: today), isFalse);
});
test('a Skipped stop parked yesterday is excluded', () {
// The skipped store stamps its own records, so this carries the
// authoritative pair — and it still must not show today.
final row = <String, dynamic>{
'orderid': 'S-1',
'orderstatus': 'skipped',
'skippedat': '2026-08-26T19:05:00.000',
'skippedday': yesterday,
};
expect(stopStatusOf(row).isSkipped, isTrue);
expect(ServiceDay.of(row), yesterday);
expect(ServiceDay.belongsToToday(row, now: today), isFalse);
});
test('a Cancelled booking from yesterday is excluded', () {
final row = apiRow(
id: 'C-1',
status: 'cancelled',
at: '2026-08-26T09:00:00Z',
key: 'updatedon',
);
expect(stopStatusOf(row).isCancelled, isTrue);
expect(ServiceDay.of(row), yesterday);
expect(ServiceDay.belongsToToday(row, now: today), isFalse);
});
test('the three slices are filtered by one rule, not three', () {
// Delivered, Skipped and Cancelled all pass through the same boundary
// filter before the page ever slices them, which is what stops the three
// tabs from disagreeing about what "today" means.
final rows = [
apiRow(id: 'D-1', status: 'delivered', at: '2026-08-26T14:20:00Z'),
apiRow(id: 'S-1', status: 'skipped', at: '2026-08-26T19:05:00Z'),
apiRow(id: 'C-1', status: 'cancelled', at: '2026-08-26T09:00:00Z'),
];
expect(
rows.where((r) => ServiceDay.belongsToToday(r, now: today)),
isEmpty,
);
});
});
group("today's records are visible", () {
test('a booking finished today is admitted', () {
final row = apiRow(
id: 'D-2',
status: 'delivered',
at: '2026-08-27T11:42:00Z',
);
expect(ServiceDay.of(row), today);
expect(ServiceDay.belongsToToday(row, now: today), isTrue);
});
test("this session's local completion stamp is proof enough", () {
// `accepted_store.dart` writes `completedat` + `completedday` at the
// moment the rider acts. That pair is the *only* record of a stop the
// backend has not caught up with, and the strict rule must not cost it.
final row = <String, dynamic>{
'orderid': 'D-3',
'orderstatus': 'picked',
'completedat': '2026-08-27T08:15:00.000',
'completedday': today,
};
expect(ServiceDay.of(row), today);
expect(ServiceDay.belongsToToday(row, now: today), isTrue);
});
test("this session's local skip stamp is proof enough", () {
final row = <String, dynamic>{
'orderid': 'S-2',
'orderstatus': 'skipped',
'skippedat': '2026-08-27T10:02:00.000',
'skippedday': today,
};
expect(ServiceDay.belongsToToday(row, now: today), isTrue);
});
test('a day stamp outranks a stale API timestamp on the same row', () {
// The merge in `_load` overlays the local store onto the API row, so both
// can be present. `dayKeys` is read first, which is what makes the
// rider's own action the answer.
final row = <String, dynamic>{
'orderid': 'D-4',
'orderstatus': 'picked',
'updatedon': '2026-08-26T22:00:00Z',
'completedday': today,
};
expect(ServiceDay.belongsToToday(row, now: today), isTrue);
});
});
group('the day boundary is IST wall-clock, not UTC', () {
// The old `of` did `DateTime.tryParse` then `toLocal()`. On a timestamp
// carrying the false `Z`, that shifted an IST evening by +5:30 — so a stop
// finished at 23:50 was filed under the *next* day and vanished from
// Activity the instant it was completed. Every evening shift lost its last
// stop. `parseStamp` strips the marker and reads the digits the backend
// meant, which is the day the rider would name.
test('23:50 stays on its own day', () {
final row = apiRow(
id: 'L-1',
status: 'delivered',
at: '2026-08-26T23:50:00Z',
);
expect(ServiceDay.of(row), yesterday);
expect(ServiceDay.belongsToToday(row, now: yesterday), isTrue);
expect(
ServiceDay.belongsToToday(row, now: today),
isFalse,
reason: 'a 23:50 stop must not leak into the next day',
);
});
test('00:10 belongs to the new day', () {
final row = apiRow(
id: 'L-2',
status: 'delivered',
at: '2026-08-27T00:10:00Z',
);
expect(ServiceDay.of(row), today);
expect(ServiceDay.belongsToToday(row, now: today), isTrue);
expect(ServiceDay.belongsToToday(row, now: yesterday), isFalse);
});
test('a stamp with no zone marker reads the same as one with a false Z', () {
final naive = apiRow(
id: 'L-3',
status: 'delivered',
at: '2026-08-26T23:50:00',
);
final falseZ = apiRow(
id: 'L-4',
status: 'delivered',
at: '2026-08-26T23:50:00Z',
);
expect(ServiceDay.of(naive), ServiceDay.of(falseZ));
});
});
group('undated rows are excluded and reported', () {
test('a historical row carrying no usable timestamp is excluded', () {
final row = <String, dynamic>{
'orderid': 'U-1',
'orderstatus': 'delivered',
};
expect(ServiceDay.of(row), isEmpty);
expect(
ServiceDay.belongsToToday(row, now: today),
isFalse,
reason: 'this branch is the hole yesterday came through',
);
});
test('exclusion is announced, not silent', () {
final row = <String, dynamic>{'orderid': 'U-2', 'orderstatus': 'skipped'};
final seen = <Map<String, dynamic>>[];
ServiceDay.belongsToToday(row, now: today, onUndated: seen.add);
expect(seen, [row], reason: 'a data defect worth counting');
});
test('a dated row never reports itself as undated', () {
final row = apiRow(
id: 'U-3',
status: 'delivered',
at: '2026-08-27T11:42:00Z',
);
var reported = false;
ServiceDay.belongsToToday(
row,
now: today,
onUndated: (_) => reported = true,
);
expect(reported, isFalse);
});
test('the fields the backend really sends are all read', () {
// The regression in one line: every one of these was invisible to the
// old key list, and each is enough on its own to date a row.
for (final key in const [
'pickedtime',
'picked_time',
'deliverytime',
'updatedon',
'modifiedon',
'expected_pickup_time',
]) {
final row = <String, dynamic>{key: '2026-08-26T14:20:00Z'};
expect(
ServiceDay.of(row),
yesterday,
reason: '$key must date a row',
);
}
});
});
group('the day turns over under an open screen', () {
// `_load` filters against the day as read at fetch time, so the list is
// only ever as fresh as the last fetch. [ServiceDayRollover] is what
// notices that the wall clock has moved past it.
test('midnight rollover reports the old day as ended', () {
var now = DateTime(2026, 8, 26, 23, 59);
final day = ServiceDayRollover(clock: () => now);
expect(day.day, yesterday);
expect(day.hasRolled, isFalse);
expect(day.rollIfNeeded(), isFalse, reason: 'still the same day');
// The timer fires a second past midnight.
now = DateTime(2026, 8, 27, 0, 0, 1);
expect(day.hasRolled, isTrue);
expect(day.rollIfNeeded(), isTrue);
expect(day.day, today);
});
test("yesterday's rows do not survive the rollover", () {
// What the page does with that answer: it clears state *before* the
// refetch, so a new day starts empty even with no signal. The alternative
// is a rider in a dead spot at 6am reading yesterday's stops under a
// heading that says Today.
var now = DateTime(2026, 8, 26, 22, 0);
final day = ServiceDayRollover(clock: () => now);
final rows = [
apiRow(id: 'D-1', status: 'delivered', at: '2026-08-26T14:20:00Z'),
apiRow(id: 'S-1', status: 'skipped', at: '2026-08-26T19:05:00Z'),
apiRow(id: 'C-1', status: 'cancelled', at: '2026-08-26T09:00:00Z'),
];
expect(
rows.where((r) => ServiceDay.belongsToToday(r, now: day.day)).length,
3,
);
now = DateTime(2026, 8, 27, 0, 0, 1);
day.rollIfNeeded();
expect(
rows.where((r) => ServiceDay.belongsToToday(r, now: day.day)),
isEmpty,
);
});
test('resume after midnight reports the old day as ended', () {
// The timer case is the one a phone is *least* likely to deliver: both
// platforms suspend timers in the background. This is the path that
// actually runs — the app was backgrounded on Activity at 22:00 and the
// rider opens it again at six.
var now = DateTime(2026, 8, 26, 22, 0);
final day = ServiceDayRollover(clock: () => now);
now = DateTime(2026, 8, 27, 6, 0);
expect(day.rollIfNeeded(), isTrue);
expect(day.day, today);
});
test('a resume inside the same day changes nothing', () {
var now = DateTime(2026, 8, 27, 9, 0);
final day = ServiceDayRollover(clock: () => now);
now = DateTime(2026, 8, 27, 17, 30);
expect(day.rollIfNeeded(), isFalse);
expect(day.day, today);
});
test('the check is idempotent, so both triggers are safe', () {
// The timer and the resume listener both land in the same place; whichever
// arrives second must find the day already current rather than clearing
// the list a second time.
var now = DateTime(2026, 8, 26, 23, 59);
final day = ServiceDayRollover(clock: () => now);
now = DateTime(2026, 8, 27, 0, 0, 1);
expect(day.rollIfNeeded(), isTrue);
expect(day.rollIfNeeded(), isFalse);
});
test('the wake lands just past the next local midnight', () {
final day = ServiceDayRollover(
clock: () => DateTime(2026, 8, 26, 23, 50),
);
expect(day.untilNextDay, const Duration(minutes: 10, seconds: 1));
});
test('the wake is correct across a month end', () {
// `day + 1` relies on `DateTime` normalising 32 August into 1 September.
final day = ServiceDayRollover(
clock: () => DateTime(2026, 8, 31, 23, 30),
);
expect(day.untilNextDay, const Duration(minutes: 30, seconds: 1));
});
});
}

View File

@@ -135,12 +135,31 @@ void main() {
expect(ServiceDay.belongsToToday(row, now: '2026-08-22'), isFalse);
});
test('a record with no date is kept, quietly', () {
// A payload defect is not evidence the work happened yesterday, and it
// is not something to print on the rider's screen either.
test('a record with no date is excluded, and reported', () {
// ── This assertion was inverted, deliberately ──
//
// It used to read `isTrue`: a payload defect was held not to be evidence
// the work happened yesterday, so an undated row was admitted to today.
// That reasoning had the burden of proof backwards. `GET /miler/bookings`
// returns the rider's whole open set, so "the API sent it" says nothing
// about *when* — and this branch turned out to be the hole yesterday's
// finished bookings came through, on a screen whose entire premise is
// that everything on it happened today.
//
// A row that cannot prove it is today's is not shown today. Nothing is
// deleted: [ServiceDay.of] still answers, both local stores keep their
// records, and the backend is untouched. It is a view filter.
//
// Still not something to print on the rider's screen — he can do nothing
// about a backend row with no timestamp — so it goes to the console
// instead of to a badge. See [ServiceDay.logUndated].
final row = {'orderid': 'x', 'orderstatus': 'delivered'};
expect(ServiceDay.belongsToToday(row), isTrue);
expect(ServiceDay.belongsToToday(row), isFalse);
expect(ServiceDay.of(row), '');
final reported = <Map<String, dynamic>>[];
ServiceDay.belongsToToday(row, onUndated: reported.add);
expect(reported, [row], reason: 'excluded, but never in silence');
});
test('a timestamp is used when no day stamp was written', () {

View File

@@ -57,7 +57,21 @@ void main() {
// One ground now, and everything on it inverts. The wash survives as
// `milerGlassSurface()` for the sheets, which sit over content and still
// want it.
testWidgets('is solid brand, so every tab shares one chrome', (
// ── The wash gave way to the brand, and the brand grew a gradient ──
//
// The four tabs wore a 7% frosted brand wash flattened against white while
// Home wore solid brand, so switching tab changed the colour of the top
// 76pt of the phone AND the colour of the status-bar glyphs — which reads
// as arriving in a different app rather than a different page.
//
// One ground now. It is [kBrandGradient] rather than a flat fill: two stops
// about eight per cent apart, enough that the bar reads as a surface with
// light on it and not enough to be seen as a gradient. A colour cannot be a
// gradient, so it rides as `flexibleSpace` — and the fill underneath stays
// the brand red, so a bar that fails to paint its surface comes out the
// right colour rather than transparent. Both halves are asserted, because
// it is the pair that makes the fallback safe.
testWidgets('is one brand ground, so every tab shares one chrome', (
tester,
) async {
await pump(
@@ -66,12 +80,23 @@ void main() {
);
final bar = tester.widget<AppBar>(find.byType(AppBar));
expect(bar.backgroundColor, ColorConstants.primary);
expect(
bar.flexibleSpace,
isNull,
reason: 'a solid ground needs no surface layered over it',
);
// ── The fill is transparent, and the surface paints ──
//
// It was the brand as a safety net behind [kBrandGradient]. It cannot be
// one now: the bar's bottom corners are rounded so the page shows through
// them (see [milerBrandSurface]), and an opaque rectangle behind a
// rounded surface fills in exactly the two corners the curve exists to
// open. What must hold is that the bar still *renders* the brand, which
// is the gradient assertion below.
expect(bar.backgroundColor, Colors.transparent);
expect(bar.flexibleSpace, isNotNull);
final painted = tester
.widgetList<DecoratedBox>(find.byType(DecoratedBox))
.map((d) => d.decoration)
.whereType<BoxDecoration>()
.where((d) => d.gradient == kBrandGradient);
expect(painted, isNotEmpty, reason: 'the bar does not wear the brand');
});
testWidgets('inverts everything drawn on it', (tester) async {
@@ -106,9 +131,10 @@ void main() {
const Scaffold(appBar: MilerAppBar(title: 'Activity')),
);
// The wash is a surface, and a surface against an untinted page is
// already an edge. A hairline under it drew that boundary a second time —
// the same doubled-up border this app has been pulling out of its cards.
// The brand ground is a surface, and a surface against an untinted page
// is already an edge. A hairline under it drew that boundary a second
// time — the same doubled-up border this app has been pulling out of its
// cards.
final bar = tester.widget<AppBar>(find.byType(AppBar));
expect(bar.bottom, isNull);
});
@@ -140,25 +166,17 @@ void main() {
);
final bar = tester.widget<AppBar>(find.byType(AppBar));
expect(bar.backgroundColor, ColorConstants.primary);
expect(bar.flexibleSpace, isNull);
// ── The fill is transparent, and the surface paints ──
//
// It was the brand as a safety net behind [kBrandGradient]. It cannot be
// one now: the bar's bottom corners are rounded so the page shows through
// them (see [milerBrandSurface]), and an opaque rectangle behind a
// rounded surface fills in exactly the two corners the curve exists to
// open. What must hold is that the bar still *renders* the brand, which
// is the gradient assertion below.
expect(bar.backgroundColor, Colors.transparent);
expect(bar.flexibleSpace, isNotNull);
expect(bar.bottom, isNull, reason: 'no rule under the bar here either');
});
});
// The wash survives as `milerGlassSurface()`, which the SHEETS use — they sit
// over scrolling content and genuinely want a translucent surface, where a
// bar sits over nothing and does not. This still pins what the wash has to
// be, wherever it is used.
test('the wash is a brand tint light enough to carry near-black type', () {
// Flattened against white, which is what the sheets composite it over.
final flat = Color.alphaBlend(ColorConstants.glassRed, Colors.white);
expect(
flat.computeLuminance(),
greaterThan(0.85),
reason:
'a saturated red surface would force white text onto a mid-tone '
'red, the weakest contrast pairing in the palette',
);
});
}

220
test/arrived_rung_test.dart Normal file
View File

@@ -0,0 +1,220 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/accepted_store.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/consignment_state.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// TWO RUNGS THAT WERE NOT THERE
///
/// Reported from a milk run, and they are the same failure at two ends of the
/// ladder: a rung the rider walked was not a rung the app kept.
///
/// 1. **Arrived did nothing.** He marked arrived at the kitchen, the row went
/// back to ACCEPTED, and the sheet offered him arrival again. The write
/// was fine. `POST /miler/bookings/:id/reached` answers 200 and leaves the
/// booking on `Miler_Assigned` (request 15), so the rung lived only as a
/// field on an in-memory row — and `_advanceStopWithSheet` finishes with
/// `_fetchQueues()`, which rebuilt that row from the server about a second
/// later. Nothing kept his report, so nothing showed it.
///
/// 2. **Picked jumped to active.** He slid to collect and the card went
/// straight to ACTIVE without ever showing PICKED, with **Start delivery**
/// never offered. `pickup-complete` in compatibility mode releases the
/// consignment itself, so the stop reached the Deliveries tab already
/// `Out_for_Delivery` — and the release gate read that as "he has set
/// off".
///
/// The ladder the rider actually walks, and what answers each rung:
///
/// ```
/// ACCEPTED the queue, or the accepted store
/// ARRIVED HIS report → the arrived store (nothing else has it)
/// PICKED pickup-complete → the collected store
/// ACTIVE HIS press → the out-for-delivery store
/// DELIVERED deliver
/// ```
///
/// Two of the five are the rider's own and cannot be read back from anywhere.
/// That is why they are stored, and it is the whole content of both bugs.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
ServiceProfile.setActive(ServiceProfile.milkMan);
});
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
/// A stop as the queue hands it over.
Map<String, dynamic> stop(String backendStatus, {String id = '1001'}) => {
'orderid': id,
'pickupid': int.parse(id),
'sourcename': 'Vidhya Kitchen',
'orderstatus': ApiConfig.legacyStatusFromNew(backendStatus),
'pickuplat': 11.01,
'pickuplon': 76.95,
'droplat': 11.05,
'droplon': 76.99,
};
group('1 · arrival is kept, because nothing else keeps it', () {
test('the server does not persist it — this is the premise', () {
// Not an assertion about our code: it is the reason the store exists.
// `reached` leaves the booking where it was, so a poll cannot return the
// rung and the app cannot read it back.
expect(
ApiConfig.legacyStatusFromNew('Miler_Assigned'),
isNot('arrived'),
reason:
'if this ever becomes "arrived" the backend has shipped request '
'15 and the arrived store should be deleted, not kept',
);
});
test('an arrival survives being written and read back', () async {
await addArrivedOrderIds(['1001']);
expect(await getArrivedOrderIds(), {'1001'});
});
test(
'the rung it produces is ARRIVED, over a queue that says accepted',
() async {
await addArrivedOrderIds(['1001']);
final arrived = await getArrivedOrderIds();
// What `_fetchQueues` does with the set: the row comes back on
// Miler_Assigned and the rider's own record puts it back on the rung.
final row = stop('Miler_Assigned');
expect(
stopStatusFromRaw(row['orderstatus']),
isNot(StopStatus.arrived),
reason: 'the server really does hand it back one rung down',
);
if (arrived.contains(row['orderid'])) row['orderstatus'] = 'arrived';
expect(
MilkRun.stageOf(
row,
acceptedIds: {'1001'},
collectedIds: const {},
outForDeliveryIds: const {},
),
StopStatus.arrived,
);
},
);
test('and it is dropped the moment the stop is collected', () async {
await addArrivedOrderIds(['1001']);
await removeArrivedOrderIds(['1001']);
expect(
await getArrivedOrderIds(),
isEmpty,
reason:
'a local record that outlives its rung pins tomorrow\'s row to '
'yesterday\'s arrival',
);
});
test('one kitchen\'s arrival does not move another\'s stop', () async {
await addArrivedOrderIds(['1001']);
expect(await getArrivedOrderIds(), isNot(contains('2002')));
});
});
group('2 · picked is not active until he says so', () {
test('compatibility mode really does release at the counter', () {
// The premise of the second bug, stated as the contract does: a
// hyperlocal booking is `Out_for_Delivery` the moment it is collected.
expect(consignmentStateFromRaw('Out_for_Delivery').isDeliverable, isTrue);
expect(
consignmentStateFromRaw('Out_for_Delivery').needsRelease,
isFalse,
reason:
'which is exactly why reading needsRelease first skipped the rung '
'— the consignment does not need releasing, the RIDER has not '
'released it',
);
});
test('the card holds at PICKED while the round is unstarted', () {
expect(
MilkRun.stageOf(
stop('Out_for_Delivery'),
acceptedIds: {'1001'},
collectedIds: {'1001'},
outForDeliveryIds: const {},
),
StopStatus.picked,
);
});
test('and moves to ACTIVE only on his own press', () {
expect(
MilkRun.stageOf(
stop('Out_for_Delivery'),
acceptedIds: {'1001'},
collectedIds: {'1001'},
outForDeliveryIds: {'1001'},
),
StopStatus.outForDelivery,
);
});
test('a delivered stop is never dragged back down the ladder', () {
// The one direction the local sets must not win in: the server knows
// something the rider's phone does not.
expect(
MilkRun.stageOf(
stop('Delivered'),
acceptedIds: {'1001'},
collectedIds: {'1001'},
outForDeliveryIds: const {},
),
StopStatus.delivered,
);
});
});
group('3 · the ladder in order, walked once', () {
test('accepted → arrived → picked → active', () async {
final row = stop('Miler_Assigned');
final collected = <String>{};
final released = <String>{};
StopStatus rung() => MilkRun.stageOf(
row,
acceptedIds: {'1001'},
collectedIds: collected,
outForDeliveryIds: released,
);
expect(rung(), StopStatus.accepted);
// He arrives.
await addArrivedOrderIds(['1001']);
row['orderstatus'] = 'arrived';
expect(rung(), StopStatus.arrived);
// He collects. pickup-complete converts and — in compatibility mode —
// releases; the rider's rung is still PICKED.
await removeArrivedOrderIds(['1001']);
row['orderstatus'] = 'picked';
row['consignmentstatus'] = 'Out_for_Delivery';
collected.add('1001');
expect(rung(), StopStatus.picked);
expect(await getArrivedOrderIds(), isEmpty);
// He slides to start the ride.
released.add('1001');
expect(rung(), StopStatus.outForDelivery);
});
});
}

View File

@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/home/trip_card.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// A STOP HE IS STANDING AT MUST NOT LEAVE THE SCREEN
///
/// Reported from a live run: the rider marked **Arrived**, the console updated
/// correctly — and the stop vanished off Home. Deliveries did not have it
/// either, and could not: arrival is a *pre-pickup* rung, and the delivery tab
/// only takes work once it is collected. The stop existed in neither place.
///
/// ── What caused it ──
///
/// Nothing on the backend. [StopState.arrived] and [StopState.active] used to
/// be one value, and `TripCard._showsOnHome` — like a dozen other sites — asked
/// `state == StopState.active` to mean *is he at the counter*. Splitting the
/// two apart so the row could say **Arrived** instead of **Active** left every
/// one of those comparisons answering `false` for the new rung.
///
/// The visibility filter was the one the rider could see. The others were just
/// as broken and quieter: the selection bar offered *Accept* for a stop he was
/// already at, the card stopped building its **Mark as picked** button, and the
/// group he was working no longer opened by default.
///
/// These pin the rung end to end — the predicate that now answers the question
/// once ([StopStateX.isAtSource]), and the screen behaviour that regressed.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
Map<String, dynamic> meal(String id, String who, {String? status}) => {
'orderid': id,
'type': 'delivery',
'step': int.parse(id),
'pickupcustomer': who,
'kitchenid': 'K1',
'kitchenname': 'Vidhya Kitchen',
'sourcename': 'Vidhya Kitchen',
'pickupcontactno': '9787698259',
'pickupaddress': '4 Cross, RS Puram, Coimbatore',
'dropaddress': '12 SNS Colony, Peelamedu, Coimbatore 641004',
'Quantity': '1',
if (status != null) 'orderstatus': status,
};
Future<void> pumpHome(
WidgetTester tester,
List<Map<String, dynamic>> stops,
) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
tester.view.physicalSize = const Size(390 * 3, 2600 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: TripCard(
trip: Trip.fromStops(id: 'run', stops: stops),
acceptedIds: const {},
selectedIds: const {},
onToggleSelect: (_) {},
onSelectAll: (_) {},
onStopTap: (_) {},
onCallStop: (_) {},
),
),
),
),
),
);
// Not `pumpAndSettle`: an arrived row is a **live** row, and [LiveMark]
// pulses for as long as it is on screen — settling waits for an animation
// that is never going to stop. Two pumps lay the frame out and let the
// pulse start, which is all these assertions need.
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
}
group('1 · the predicate that answers it once', () {
test('arrived and active both mean "he is at the source"', () {
expect(StopState.arrived.isAtSource, isTrue);
expect(StopState.active.isAtSource, isTrue);
});
test('and nothing else does', () {
for (final other in const [
StopState.pending,
StopState.accepted,
StopState.collected,
StopState.done,
StopState.skipped,
StopState.rejected,
]) {
expect(
other.isAtSource,
isFalse,
reason: '$other must not read as standing at the counter',
);
}
});
test('an arrived stop is committed work, not an open offer', () {
// It rode in on `active` and was dropped by the same split. A rider who
// has reported an arrival has plainly committed to the stop.
expect(StopState.arrived.isCommitted, isTrue);
expect(StopState.pending.isCommitted, isFalse);
});
});
group('2 · the screen behaviour that regressed', () {
testWidgets('an arrived stop stays on Home', (tester) async {
await pumpHome(tester, [
meal('1', 'Joe Mathew', status: 'arrived'),
meal('2', 'Arun Prakash'),
]);
expect(
find.text('Joe Mathew'),
findsOneWidget,
reason:
'the rider is standing at this counter — it cannot leave Home, '
'and Deliveries will not take it until it is collected',
);
});
testWidgets('it is still there beside an untouched stop', (tester) async {
// Guards the opposite overcorrection: making arrived visible must not
// have cost the ordinary rows their place in the run.
await pumpHome(tester, [
meal('1', 'Joe Mathew', status: 'arrived'),
meal('2', 'Arun Prakash'),
]);
expect(find.text('Joe Mathew'), findsOneWidget);
expect(find.text('Arun Prakash'), findsOneWidget);
});
testWidgets('a collected stop still leaves — that one is not a bug', (
tester,
) async {
// The filter's real job, asserted so a fix for the disappearance cannot
// quietly turn into "nothing ever leaves Home".
await pumpHome(tester, [
meal('1', 'Joe Mathew', status: 'picked'),
meal('2', 'Arun Prakash'),
]);
expect(find.text('Joe Mathew'), findsNothing);
expect(find.text('Arun Prakash'), findsOneWidget);
});
});
}

View File

@@ -86,13 +86,27 @@ void main() {
//
// So: no rule, no disclosure, one card that is always in the state the
// rider previously had to discover. That costs height, and the budget says
// how much — enough for a head, a divider and one row of four figures, and
// how much — enough for a head, a bar, and the four facts two-by-two, and
// not enough for a fifth thing to be added without a decision.
//
// ── 190 → 245 ──
//
// Raised deliberately, once, when the four journey figures came out of the
// fold and onto the face. It buys the thing the fold was costing: a rider
// could not see the day's duration, distance, parcel count or cash without
// pressing something, and the press was rare enough that the figures were
// effectively shipped and unreachable.
//
// The 55 points are paid for elsewhere in the same change — the header
// collapses to 52 on scroll where it used to be a permanent 160, and the
// run's ratio came off this card because the header states it. The guard
// stays because the failure mode is unchanged: a card that grows a line at
// a time until the route is below the fold again.
testWidgets('stays within the card budget', (tester) async {
final h = await briefHeight(tester);
expect(
h,
lessThan(190),
lessThan(245),
reason:
'measured $h — the brief is the permanent furniture above the '
'route, so every point of it is charged at every scroll position',
@@ -121,46 +135,41 @@ void main() {
),
);
// ── Reversed, on an explicit product call ──
// ── Reversed twice, and this is the third position ──
//
// This once required the four figures ABSENT until tapped; then it was
// flipped to require them present, on the argument that a once-a-shift
// read behind a chevron nobody presses is information the app has and
// the rider does not. That argument is about *discoverability* and it
// was answered by a measurement: on a 390×844 phone the four columns
// plus the trip tabs plus the app bar reach the fold before the first
// kitchen, so Home opened on a dashboard about the run instead of on
// the run.
// The four figures were once required ABSENT until tapped, on a
// measurement: four columns plus the trip tabs plus the app bar reached
// the fold before the first kitchen, so Home opened on a dashboard about
// the run instead of on the run.
//
// The founder's call is that the figures are reference material and the
// route is the screen. What stays out is the progress face — stops left,
// and cash in hand — which are the two a rider acts on mid-shift.
// What changed is the space, not the argument. The header collapses now,
// the tabs are a row of pills rather than a strip, and the figures are
// two-by-two rather than four across — so the same information costs
// about half the vertical it did when it was banished, and the fold it
// was pushing the route past is no longer where it was.
//
// So they are on the face, in sentence case, with the run's ratio taken
// OFF the card entirely — the header states that now.
for (final label in const [
'DURATION',
'DISTANCE',
'PARCELS',
'PAYMENT',
'Duration',
'Distance',
'Parcels',
'Payment',
]) {
expect(
find.text(label),
findsNothing,
reason: '"$label" is reference; it is one tap away, not permanent',
findsOneWidget,
reason: '"$label" is on the face, not one tap away',
);
}
// And the face itself is still out, unasked.
// The face's own headline is still the thing a rider acts on.
expect(find.textContaining('stop'), findsWidgets);
// One tap brings the whole set back.
// The fold keeps what it is actually for: the shift and slot windows,
// read once in the morning.
await tester.tap(find.byType(TripBriefStrip));
await tester.pumpAndSettle();
for (final label in const ['DURATION', 'DISTANCE', 'PARCELS']) {
expect(
find.text(label),
findsOneWidget,
reason: '$label after the tap',
);
}
expect(
find.text('CURRENT SHIFT'),
findsNothing,
@@ -217,11 +226,18 @@ void main() {
});
});
group('Trip tabs', () {
testWidgets('fit in one compact row', (tester) async {
// ── The trip selector moved into the header ──
//
// Its height and its three-slot rule are pinned in `trip_tabs_pinned_test`,
// against the widget's own declared height rather than against a sliver
// extent it no longer has. What is left here is the density guard: it is
// chrome the rider sees at every scroll position, so its height is charged
// continuously and must not creep.
group('Trip selector', () {
testWidgets('stays inside its own height budget', (tester) async {
await pump(
tester,
TripTabs(
TripSelector(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
Trip.fromStops(id: 't2', stops: [stop('B')]),
@@ -231,37 +247,25 @@ void main() {
),
);
final h = tester.getSize(find.byType(TripTabs)).height;
// ── This bound was raised deliberately, and it cost something ──
//
// The strip went from one line (~41pt) to a segmented control carrying a
// label, a status line and a running-indicator (~70pt). That is ~30pt of
// *permanent* height, and it is worse than it sounds because the bar is
// now a pinned sliver: it is on screen at every scroll position, not just
// at the top.
//
// What it buys is the separation of "the trip I am looking at" from "the
// trip that is running", which the one-line version could not express at
// all, plus per-trip progress in words rather than an ambiguous bar. That
// is a real trade, taken on purpose — but the guard stays, because the
// failure mode here is a strip that keeps growing a line at a time until
// it crowds out the route it is supposed to be navigating.
final h = tester.getSize(find.byType(TripSelector)).height;
// The old segmented strip carried a numbered disc, the word "Trip" and a
// status line, and came to ~70pt. This carries a numeral and a bar, and
// is bigger *per target* at the same overall height — the budget did not
// move, the content did.
expect(
h,
lessThan(80),
reason:
'the tab strip is pinned furniture — it is on screen at '
'every scroll position, so its height is charged continuously',
'the selector is header furniture — it is on screen at every '
'scroll position, so its height is charged continuously',
);
expect(tester.takeException(), isNull);
});
testWidgets('still show three slots before the hub assigns them', (
tester,
) async {
testWidgets('names three trips, and says only that', (tester) async {
await pump(
tester,
TripTabs(
TripSelector(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
],
@@ -269,9 +273,13 @@ void main() {
onSelect: (_) {},
),
);
expect(find.text('Trip 1'), findsOneWidget);
expect(find.text('Trip 2'), findsOneWidget);
expect(find.text('Trip 3'), findsOneWidget);
for (final n in ['Trip 1', 'Trip 2', 'Trip 3']) {
expect(find.text(n), findsOneWidget);
}
// The run's count belongs to the card below, in type four times the
// size. A filter says what it filters to. See [TripSelector].
expect(find.text('0 / 1'), findsNothing);
expect(find.text('Not set'), findsNothing);
});
});
@@ -599,7 +607,7 @@ void main() {
);
});
testWidgets('only on START · HUB, never on RETURN · HUB', (tester) async {
testWidgets('only on START · BASE, never on RETURN · BASE', (tester) async {
await pump(
tester,
TripCard(
@@ -614,16 +622,26 @@ void main() {
);
expect(
tester.getRect(find.text('Map')).top,
lessThan(tester.getRect(find.text('RETURN · HUB')).top),
lessThan(tester.getRect(find.text('RETURN · BASE')).top),
);
});
});
group('Trip tab states', () {
// ── The per-segment colour test is gone with the word it probed ──
//
// It found each segment by the shared text "Trip" and read that Text's
// colour. There is no shared word any more — a segment is a numeral and a
// bar — and what the test was really guarding, that the selected trip reads
// as picked, is now carried by the travelling marker rather than by ink on a
// noun. `trip_tabs_pinned_test` measures the marker against the numeral it
// belongs to, which is the same guarantee stated in the terms the control
// now uses.
group('Trip selector marker', () {
Future<void> pumpTabs(WidgetTester tester, {required int selected}) async {
await pump(
tester,
TripTabs(
TripSelector(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
Trip.fromStops(id: 't2', stops: [stop('B')]),
@@ -634,58 +652,7 @@ void main() {
);
}
Color colourOf(WidgetTester tester, String label) =>
tester.widget<Text>(find.text(label)).style!.color!;
testWidgets('the selected tab takes the brand accent', (tester) async {
// The bug: the pill moved but the label stayed slate, so standing on
// Trip 2 looked no different from Trip 2 sitting idle.
await pumpTabs(tester, selected: 1);
expect(
colourOf(tester, 'Trip 2'),
ColorConstants.primary,
reason: 'the tab the rider is on must read as picked',
);
expect(colourOf(tester, 'Trip 1'), isNot(ColorConstants.primary));
await pumpTabs(tester, selected: 0);
expect(colourOf(tester, 'Trip 1'), ColorConstants.primary);
expect(
colourOf(tester, 'Trip 3'),
isNot(ColorConstants.primary),
reason: 'an unassigned slot stays quiet',
);
});
testWidgets('the strip stays compact after the state work', (tester) async {
await pumpTabs(tester, selected: 0);
final h = tester.getSize(find.byType(TripTabs)).height;
expect(
h,
lessThan(66),
reason:
'measured $h — the segmented strip is pinned furniture, so '
'every point of it is charged at every scroll position',
);
});
});
group('Trip tab selection marker', () {
Future<void> pumpTabs(WidgetTester tester, {required int selected}) async {
await pump(
tester,
TripTabs(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
Trip.fromStops(id: 't2', stops: [stop('B')]),
],
selectedIndex: selected,
onSelect: (_) {},
),
);
}
/// Same tree, but leaves the pill mid-flight so it can be sampled.
/// Same tree, but left mid-flight so the marker can be sampled.
Future<void> pumpTabsNoSettle(
WidgetTester tester, {
required int selected,
@@ -696,7 +663,7 @@ void main() {
builder: (_, _) => MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: TripTabs(
child: TripSelector(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
Trip.fromStops(id: 't2', stops: [stop('B')]),
@@ -714,128 +681,101 @@ void main() {
/// The travelling selection marker.
///
/// Found by key, not by colour. It moved out of the segments and into the
/// pill when it was made to travel, and colour cannot identify it any more:
/// crossing an unassigned trip it is deliberately grey rather than the brand
/// red, so a colour finder loses it exactly during the journey these tests
/// exist to measure.
Rect underline(WidgetTester tester) {
final marks = find.byKey(TripTabs.markerKey);
/// Found by key, not by colour. It moved out of the segments and into a
/// single travelling object when it was made to travel, and colour cannot
/// identify it: the segments themselves fade between two inks as it passes,
/// so a colour finder loses it exactly during the journey these tests exist
/// to measure.
Rect marker(WidgetTester tester) {
final marks = find.byKey(TripSelector.markerKey);
expect(
marks,
findsOneWidget,
reason: 'exactly one tab may be marked at a time',
reason: 'exactly one trip may be marked at a time',
);
return tester.getRect(marks);
}
/// One tab's label.
Rect numeral(WidgetTester tester, int slot) =>
tester.getRect(find.text('Trip ${slot + 1}'));
// The marker followed a separate "this trip is running" signal for a while,
// which left Trip 1 permanently underlined while the rider stood on Trip 2 —
// so a tap appeared to do nothing. The marker belongs to selection.
testWidgets('the underline sits under the selected tab', (tester) async {
await pumpTabs(tester, selected: 0);
final first = underline(tester);
final trip1 = tester.getRect(find.text('Trip 1'));
expect(
first.center.dx,
closeTo(trip1.center.dx, 2),
reason: 'the marker should be under Trip 1',
);
});
testWidgets('and travels when a different tab is picked', (tester) async {
await pumpTabs(tester, selected: 0);
final before = underline(tester).center.dx;
// which left trip 1 permanently marked while the rider stood on trip 2 — so
// a tap appeared to do nothing. The marker belongs to selection.
testWidgets('the marker sits under the selected pill', (tester) async {
await pumpTabs(tester, selected: 1);
await tester.pumpAndSettle();
final after = underline(tester).center.dx;
final trip2 = tester.getRect(find.text('Trip 2'));
expect(
after,
greaterThan(before),
reason:
'the marker moved from $before to $after — it must follow '
'the selection rightward to Trip 2',
);
expect(after, closeTo(trip2.center.dx, 2));
final m = marker(tester);
final second = numeral(tester, 1);
// A short centred rule, not a full-width underline — so it is centred on
// its pill rather than spanning it.
expect(m.center.dx, closeTo(second.center.dx, 6));
expect(m.top, greaterThan(second.bottom));
});
testWidgets('Trip 3 → Trip 1 slides across Trip 2 rather than jumping', (
testWidgets('and travels when a different segment is picked', (
tester,
) async {
// The pill used to belong to each segment: one faded out on the right
// while a *different* one faded in on the left, so nothing ever occupied
// the space between and there was no object to cross Trip 2 with.
await pumpTabs(tester, selected: 2);
await tester.pumpAndSettle();
final trip2 = tester.getRect(find.text('Trip 2'));
final start = underline(tester).center.dx;
expect(
start,
greaterThan(trip2.center.dx),
reason: 'sanity: the marker starts to the right of Trip 2',
);
await pumpTabs(tester, selected: 0);
final first = marker(tester).center.dx;
await pumpTabs(tester, selected: 2);
final third = marker(tester).center.dx;
expect(
third,
greaterThan(first),
reason: 'the marker did not move with the selection',
);
});
testWidgets('3 → 1 slides across 2 rather than jumping', (tester) async {
// The regression this whole mechanism exists for: the marker used to be
// each segment's own fill, so crossing two slots faded one out on the
// right and a different one in on the left. Nothing ever occupied the
// space between.
await pumpTabs(tester, selected: 2);
final middle = numeral(tester, 1);
// `pumpTabs` pumps and settles; drive the frames by hand instead so the
// journey can be sampled.
await pumpTabsNoSettle(tester, selected: 0);
final samples = <double>[];
for (var elapsed = 0; elapsed < 420; elapsed += 20) {
for (var i = 0; i < 20; i++) {
await tester.pump(const Duration(milliseconds: 20));
samples.add(underline(tester).center.dx);
samples.add(marker(tester).center.dx);
}
final half = trip2.width / 2;
final half = middle.width;
expect(
samples.any((x) => (x - trip2.center.dx).abs() < half),
samples.any((x) => (x - middle.center.dx).abs() < half * 3),
isTrue,
reason: 'the marker never passed over Trip 2; samples: $samples',
reason: 'the marker never passed over tab 2; samples: \$samples',
);
for (var i = 1; i < samples.length; i++) {
expect(
samples[i],
lessThanOrEqualTo(samples[i - 1] + 0.5),
reason: 'the marker moved backwards at sample $i: $samples',
reason: 'the marker moved backwards at sample \$i: \$samples',
);
}
await tester.pumpAndSettle();
expect(
underline(tester).center.dx,
closeTo(tester.getRect(find.text('Trip 1')).center.dx, 2),
);
});
testWidgets('the pill is the brand tint, not a white card', (tester) async {
// It was a white pill with a drop shadow, which made this the one
// selected-state in the app that was not the brand colour — the two
// controls a rider uses most looked like they came from different apps.
await pumpTabs(tester, selected: 0);
final fills = tester
.widgetList<Container>(find.byType(Container))
.map((c) => c.decoration)
.whereType<BoxDecoration>()
.toList();
final glass = ColorConstants.primary.withValues(alpha: 0.12);
expect(
fills.map((d) => d.color),
contains(glass),
reason:
'the selection pill should be the light glass red the bottom '
'nav bar uses',
);
expect(
fills.where(
(d) => d.color == Colors.white && (d.boxShadow?.isNotEmpty ?? false),
),
isEmpty,
reason: 'no raised white pill should remain',
);
testWidgets('exactly one marker, whatever is selected', (tester) async {
// ── This assertion has been on both sides, and the principle held ──
//
// It began as "no raised white pill" — the bar was bare words with a
// travelling tint behind them, and a white card would have been the
// loudest object on a page whose brief was that containers recede. Then
// the bar became a segmented control on the canvas, and the pill was the
// design rather than the bug. It is now a white marker on the brand
// header, where white is simply the only mark that reads.
//
// What has to hold through all three is the part that always did: **one**
// mark of selection, never three.
for (final selected in const [0, 1, 2]) {
await pumpTabs(tester, selected: selected);
expect(find.byKey(TripSelector.markerKey), findsOneWidget);
}
});
});
@@ -936,10 +876,23 @@ void main() {
// chip, and there is no bound on how many of those a shift produces. Six
// stops in, the rider was scrolling past six rows he could do nothing with to
// reach the one he could. They move to the Activity tab — but the *trip*
// keeps them, because the brief's remaining count and its parcel figure are
// counted from it.
// object keeps them, because the numbering and the progress head are
// measured against a total that must not move.
//
// ── What the brief's four figures do NOT do ──
//
// They used to be counted from that same total, so duration, distance and
// the parcel count described the slot as assigned and never moved while the
// rider worked it. `20 stops left` counted down above four figures that
// still described the whole morning, including everything already collected
// and handed to Deliveries.
//
// They are counted from [Trip.outstanding] now — the collections still owed
// — so the two halves of the card finally answer the same question. The
// trip's own totals are untouched, which is what the first two assertions
// below are for.
group('completed stops', () {
testWidgets('are not listed, but still count towards the trip', (
testWidgets('leave the list and the figures, but not the trip', (
tester,
) async {
final trip = Trip.fromStops(
@@ -971,34 +924,44 @@ void main() {
// a trip of one with none done both say. The parcel count can only reach
// 2 if the finished stop is still part of the trip, which is the whole
// contract here.
// The hero sets the figure and its caption as two texts — numeral at
// headline scale, words as its caption — so the claim is asserted on
// the pair rather than on one sentence.
// The headline is one string now — `1 stop left` — rather than a numeral
// at headline scale beside its caption at body scale. Two sizes and a
// manual baseline nudge to make a figure and its unit look like one
// thing, when they are one thing.
expect(
find.descendant(
of: find.byType(TripBriefStrip),
matching: find.text('1'),
matching: find.text('1 stop left'),
),
findsOneWidget,
);
expect(find.text('stop left'), findsOneWidget);
final singleStop = Trip.fromStops(id: 't2', stops: [stop('B')]);
expect(
trip.totalParcels,
greaterThan(singleStop.totalParcels),
reason: 'the collected stop still contributes its load',
reason: 'the TRIP still carries the collected stop and its load',
);
// The parcel figure is reference now, so it is behind the fold — but it
// must still be the *trip's* total rather than the visible one, which is
// the contract this test exists for.
// ── But the brief states what is left, not what the slot held ──
//
// Stop A is `picked`, so it is across the boundary and belongs to
// Deliveries. One collection is still owed, and the parcel figure says
// one — the trip's own total of two is deliberately NOT what is drawn.
final outstanding = trip.outstanding();
expect(
outstanding.totalParcels,
lessThan(trip.totalParcels),
reason: 'a collected stop is no longer work in front of the rider',
);
await openBrief(tester);
expect(
find.descendant(
of: find.byType(TripBriefStrip),
matching: find.text('${trip.totalParcels}'),
matching: find.text('${outstanding.totalParcels}'),
),
findsOneWidget,
reason: 'and the card states that total, not the visible one',
reason: 'the card states the outstanding count, not the slot total',
);
});
@@ -1041,12 +1004,16 @@ void main() {
//
// The brief and the route now stand on [MilerPanel], layer 1 of the
// surface ladder, because Home's ground is the canvas and nothing white
// was standing on it. The panel owns the inset: `panelGutter` 12 outside
// the white plus `panelPad` 14 inside it, and `TripCard` no longer adds a
// gutter of its own. So both land on 26, and — the part that matters —
// they land on the *same* number. `home_gutter_test` holds them together.
const homeGutter = 26.0;
const stopRowInset = 26.0;
// was standing on it. The panel owns the inset, and `TripCard` no longer
// adds a gutter of its own — so both land on the *same* number, which is
// the part that matters. `home_gutter_test` holds them together.
//
// 26 → 29: `panelGutter` 12 outside the white, `panelPad` 16 inside it,
// and one point for the hairline the card carries now (see [Narrative] —
// the border is half of what buys a card its separation on the lighter
// ground). A pixel of border is a pixel of inset.
const homeGutter = 29.0;
const stopRowInset = 29.0;
testWidgets('the summary cards and the stop rows each keep their inset', (
tester,
@@ -1401,15 +1368,23 @@ void main() {
);
});
// ── The disclosure has to LOOK like one ──
// ── The header's right edge belongs to the journey ──
//
// The chevron was removed once when the header became the toggle, and its
// signal went with it: a collapsed kitchen showed nothing that said "there
// is a list under me". It is back as a passive indicator inside the
// header's own gesture — one arena, one owner — and this holds the three
// facts that make it honest: it exists, it turns with the state, and a
// flat stop (whose tap navigates instead) wears the pointing variant.
testWidgets('the header wears a chevron that turns with the state', (
// A chevron has been on and off this header three times, and the argument
// each way was about what the right edge was *for*. It came back once as a
// passive indicator, because a collapsed kitchen showed nothing that said
// "there is a list under me".
//
// The corner is not empty any more: the distance and the ride time live
// there, and they are the reason a rider looks at it — down a multi-kitchen
// run they form the column that answers *which of these is nearest*. A
// glyph between them and the card's edge pushed that column ~24 points off
// the margin so it lined up with nothing.
//
// What is asserted now is the trade: no fold mark on a group header, and
// the journey column sitting on the card's own right edge. The fold state
// is still legible — an open group has its orders on screen under it.
testWidgets('the group header ends on its journey, not a chevron', (
tester,
) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
@@ -1426,43 +1401,45 @@ void main() {
'kitchenname': 'Vidhya Kitchen',
'Quantity': 1,
'orderstatus': 'new',
'pickuplat': 11.04,
'pickuplon': 76.98,
},
];
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't', stops: stops),
riderLat: 11.0168,
riderLng: 76.9558,
onToggleSelect: (_) {},
onStopTap: (_) {},
),
);
// Scoped to the route. The trip brief above it also folds, and since
// Home moved to one icon family both disclosure marks are the same
// glyph — correctly, they mean the same thing — so a bare `byIcon` here
// matches the brief's chevron as well and stops being a statement about
// the group header at all.
final chevron = find.descendant(
of: find.byType(RouteTimeline),
matching: find.byIcon(LucideIcons.chevronDown),
// Scoped to the route: the trip brief above it still folds and wears the
// same glyph, so an unscoped finder would be a claim about the whole card.
expect(
find.descendant(
of: find.byType(RouteTimeline),
matching: find.byIcon(LucideIcons.chevronDown),
),
findsNothing,
reason: 'the corner is the journey column, not a fold mark',
);
expect(chevron, findsOneWidget);
double turns() => tester
.widget<AnimatedRotation>(
find.ancestor(of: chevron, matching: find.byType(AnimatedRotation)),
)
.turns;
final openTurns = turns();
// And the group still folds — the gesture was always the header's, and
// removing the glyph must not have taken the behaviour with it.
// Measured by height rather than by the rows' presence: the body
// collapses through an `AnimatedSize`, which keeps its child mounted at
// zero height, so a `findsNothing` here would be asserting an
// implementation detail of the animation rather than the fold.
final open = tester.getSize(find.byType(RouteTimeline)).height;
await tester.tap(find.text('Vidhya Kitchen'));
await tester.pumpAndSettle();
expect(
turns(),
isNot(openTurns),
reason:
'the chevron must turn when the list opens or closes — a '
'static disclosure mark is a printed one',
tester.getSize(find.byType(RouteTimeline)).height,
lessThan(open),
reason: 'tapping the header still closes its orders',
);
});

View File

@@ -106,8 +106,9 @@ void main() {
test('a genuine hub hold stays blocked — the logistics guard', () {
// The guard the brief requires be kept: a consignment inside the hub's
// half of the network is not this rider's to hand over.
//
// `Created` is deliberately NOT in this list — see the test below.
for (final s in const [
ConsignmentState.created,
ConsignmentState.inwardedAtHub,
ConsignmentState.tripsheetLoaded,
ConsignmentState.inTransit,
@@ -118,6 +119,36 @@ void main() {
}
});
test('`Created` is the rider\'s own parcel, not a hub hold', () {
// The bug this pins: `Created` was in the hub list, so sliding **Start
// ride** on a freshly converted consignment answered "This parcel is
// with the hub — it will be delivered from there, not by you" about a
// bag on the rider's own back, and nothing on that screen cleared it.
//
// The backend writes `Created` at the pivot for hub-routed work
// (`next_action: inward_at_hub`) and holds it there transiently while
// hyperlocal work is routed. The hub has not taken it in either way.
const s = ConsignmentState.created;
expect(s.awaitsHub, isFalse, reason: 'the hub has never seen it');
expect(s.awaitsHubInward, isTrue);
expect(ConsignmentGate.gateFor(s), DeliverGate.awaitingInward);
// Still not deliverable: `deliver` only accepts `Out_for_Delivery`. The
// fix changes which sentence the rider is shown, never what is claimed.
expect(s.isDeliverable, isFalse);
expect(s.needsRelease, isFalse);
// And no other state may drift into the new rung.
for (final other in ConsignmentState.values) {
if (other == ConsignmentState.created) continue;
expect(
other.awaitsHubInward,
isFalse,
reason: '${other.name} is not the pre-inward rung',
);
}
});
test('closed work is refused without blaming the rider', () {
for (final s in const [
ConsignmentState.cancelled,

View File

@@ -7,6 +7,7 @@ import 'package:miler/data/order_events.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/activity/delivery_details_page.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/narrative.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// DELIVERY DETAILS — one stop, the whole story
@@ -111,6 +112,20 @@ void main() {
}
}
/// Opens the full journey log.
///
/// The rungs are behind **View full log** now: the panel leads with a
/// one-line strip of the same ladder, and the detailed version — clocks,
/// places, addresses, the gaps between them — is one tap under it. Every
/// assertion about *which* rungs exist and in what order is unchanged; what
/// changed is that reading them costs a tap, so the tests take it.
Future<void> openLog(WidgetTester tester) async {
final link = find.text('View full log');
if (link.evaluate().isEmpty) return;
await tester.tap(link);
await tester.pumpAndSettle();
}
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
group('the summary', () {
@@ -124,7 +139,11 @@ void main() {
// stop cannot be called one thing on the list and another on the page
// that list opens. It appears twice: the summary and the timeline's head
// rung, which is the newest event.
expect(find.text('Delivered'), findsNWidgets(2));
// Once, not twice. The timeline's own copy of the word is behind
// **View full log** now — the strip above it carries `DELIVERED`, and
// the card under it says where — so the only `Delivered` on a closed
// record page is the verdict itself.
expect(find.text('Delivered'), findsOneWidget);
final headline = tester.widget<Text>(find.text('Delivered').first);
expect(headline.style!.color, ColorConstants.acceptGreen);
@@ -175,12 +194,15 @@ void main() {
testWidgets('runs oldest first, named for the leg', (tester) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(tester, record());
await openLog(tester);
// The door arrival is not a rung: it folds into the outcome as a
// duration, because "he got there 5m before he handed it over" is a
// subtraction, not an event worth its own line.
expect(find.text('Arrived at customer'), findsNothing);
expect(find.text('5m at the door'), findsOneWidget);
// Twice with the log open: on the rung, and on the card under the strip
// that restates how the last leg went.
expect(find.text('5m at the door'), findsNWidgets(2));
expect(find.text('Out for delivery'), findsOneWidget);
// ── Downwards, like every order-tracking screen ──
@@ -199,6 +221,7 @@ void main() {
testWidgets('a collection is named for its own leg', (tester) async {
await pump(tester, record(type: 'pickup', status: 'picked'));
await openLog(tester);
expect(find.text('Picked up'), findsWidgets);
// On a collection `arrivedat` IS the counter, so it fills the same rung
@@ -224,6 +247,7 @@ void main() {
arrived: false,
),
);
await openLog(tester);
expect(find.text('Set off'), findsNothing);
expect(find.text('Arrived'), findsNothing);
@@ -250,6 +274,7 @@ void main() {
tester,
record(type: 'pickup', status: 'picked', arrived: false),
);
await openLog(tester);
expect(find.text('Set off'), findsOneWidget);
expect(find.text('Arrived'), findsNothing);
});
@@ -280,6 +305,7 @@ void main() {
.add(const Duration(minutes: 65))
.toIso8601String(),
});
await openLog(tester);
for (final rung in const [
'Assigned',
@@ -335,13 +361,15 @@ void main() {
// existed. It must degrade to what it has, not to a page of placeholders.
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(tester, record());
await openLog(tester);
expect(find.text('Delivered'), findsNWidgets(2));
// The door arrival is not a rung: it folds into the outcome as a
// duration, because "he got there 5m before he handed it over" is a
// subtraction, not an event worth its own line.
expect(find.text('Arrived at customer'), findsNothing);
expect(find.text('5m at the door'), findsOneWidget);
// The rung, and the card under the strip.
expect(find.text('5m at the door'), findsNWidgets(2));
expect(find.text('Out for delivery'), findsOneWidget);
for (final absent in const [
'Order placed',
@@ -363,18 +391,22 @@ void main() {
tester,
record(status: 'skipped', skipReason: 'Customer unreachable'),
);
await openLog(tester);
// The one rung on this page allowed to exist without a timestamp: a
// skipped stop has a delivery in its story that did not occur, and saying
// so is the point of the record. It is an open ring, not a filled disc,
// so it cannot read as a step that is merely pending.
expect(find.text('Delivered'), findsOneWidget);
expect(find.text('Not completed — return visit owed'), findsOneWidget);
// The unreached rung, and the card under the strip that names where the
// story stopped.
expect(find.text('Delivered'), findsNWidgets(2));
expect(find.text('Not completed — return visit owed'), findsNWidgets(2));
});
testWidgets('a completed stop has nothing left unreached', (tester) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(tester, record());
await openLog(tester);
// Twice — the summary and the timeline's last rung — and never a third
// time as a step still to come.
@@ -388,12 +420,13 @@ void main() {
testWidgets('a round draws both ends; a parcel draws one', (tester) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(tester, record());
await openLog(tester);
// ── ROUTE is gone; its two addresses moved onto the journey ──
//
// Tracking said *when*, Route said *where*, and the rider joined them
// himself. Each place is now written onto the rung that happened there.
expect(find.text('ROUTE'), findsNothing);
expect(find.text('JOURNEY'), findsOneWidget);
expect(find.text('Journey'), findsOneWidget);
// Once now, not twice. The hero's quiet `from → to` line names both
// ends; the journey names the place on the rung that happened there,
@@ -402,11 +435,14 @@ void main() {
expect(find.text('Vidhya Kitchen'), findsOneWidget);
expect(
find.text('78, Cross Cut Road, Gandhipuram'),
findsOneWidget,
findsNWidgets(2),
reason:
'the address hangs on the rung that happened at it. With no '
'drop address on the booking the one address it has belongs to '
'the door — the fallback ROUTE encoded and this inherited.',
'the door — the fallback ROUTE encoded and this inherited. '
'Twice with the log open: the rung, and the card under the strip '
'that restates where it ended, which is the one line the strip '
'itself has no room for.',
);
// A parcel booking is a first-mile collection *from* the customer, and
@@ -415,7 +451,7 @@ void main() {
ServiceProfile.setActive(ServiceProfile.parcel);
await pump(tester, record(type: 'pickup', status: 'picked'));
expect(find.text('ROUTE'), findsNothing);
expect(find.text('JOURNEY'), findsOneWidget);
expect(find.text('Journey'), findsOneWidget);
});
});
@@ -431,23 +467,31 @@ void main() {
);
await unfold(tester);
// ── The labelled stats strip is gone ──
// ── The stats strip is back, as the app's own fact strip ──
//
// DISTANCE / TOTAL TIME / ORDERS was three captioned cells in a tinted
// box — a dashboard widget, and a dashboard is for comparing many things
// across records. There is one stop on this page and it is finished. The
// figures still earn a place; they earn a line.
expect(find.text('DISTANCE'), findsNothing);
expect(find.text('TOTAL TIME'), findsNothing);
// It was removed once, as DISTANCE / TOTAL TIME / ORDERS in a tinted box,
// on the argument that a dashboard is for comparing many things and there
// is one stop on this page. That argument was about the *panel*; what
// replaced it — one grey centred sentence — went too far the other way,
// and the page it left behind read as unfinished.
//
// What is here now is the tonal fact strip Account and the stop sheet
// already use, inside the panel that owns the figures. So the assertion
// flips: the cells exist, and what is still forbidden is a cell for
// something nobody measured (below).
expect(find.text('DISTANCE'), findsOneWidget);
// The distance now reads once in that line, and once in the folded row
// that names it.
expect(find.text('4.6 km'), findsOneWidget);
// Twice, and both are honest: the strip's value, and the folded row that
// names it against the planned figure.
expect(find.text('4.6 km'), findsNWidgets(2));
expect(find.textContaining('4.6 km'), findsWidgets);
expect(find.text('4.1 km'), findsOneWidget);
// The brief's emerald, which is what "delivered / met / yes" is set in
// on the rebuilt screens. Same meaning, one hue deeper than the old
// `acceptGreen`.
final verdict = tester.widget<Text>(find.text('Yes'));
expect(verdict.style!.color, ColorConstants.acceptGreen);
expect(verdict.style!.color, Narrative.emerald);
// A record that invents a plausible number cannot be caught.
for (final invented in const ['0 km', '0.0 km', '0m', '₹0']) {
@@ -474,7 +518,9 @@ void main() {
testWidgets('the reference is there and copyable', (tester) async {
await pump(tester, record(type: 'pickup', status: 'picked'));
await unfold(tester);
expect(find.text('#DM101'), findsOneWidget);
// Twice: the app bar's quiet identifier and the folded row that owns
// it. Only one of them is copyable, which is the point of the row.
expect(find.text('#DM101'), findsNWidgets(2));
expect(find.byIcon(LucideIcons.copy), findsOneWidget);
});
@@ -518,9 +564,10 @@ void main() {
await unfold(tester);
expect(find.text('Payment'), findsWidgets);
// Due, collected, and again under EARNINGS — the cash that changed hands
// is the one earnings figure this stop really has.
expect(find.text('₹340'), findsNWidgets(3));
// Four: the fact strip at the top of the page, then due, collected, and
// again under EARNINGS — the cash that changed hands is the one earnings
// figure this stop really has.
expect(find.text('₹340'), findsNWidgets(4));
// Earnings folded into Payment — one group about money, not two.
expect(find.text('Payment'), findsWidgets);
expect(find.text('Cash collected'), findsOneWidget);
@@ -576,11 +623,14 @@ void main() {
// Both addresses now sit on the journey, each on the rung that happened
// at it — the counter on the collection, the door on the outcome — so
// this reads the ladder rather than a separate ROUTE section.
await openLog(tester);
final kitchen = tester.getRect(
find.text('4 Cross, RS Puram, Coimbatore 641002'),
);
// `.first`: the door's address is on its rung and again on the card
// under the strip, and the rung is the one this ordering is about.
final door = tester.getRect(
find.text('12, SNS Colony, Peelamedu, Coimbatore 641004'),
find.text('12, SNS Colony, Peelamedu, Coimbatore 641004').first,
);
expect(
@@ -600,16 +650,22 @@ void main() {
// hanging its one address exactly where it always hung.
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(tester, record());
await openLog(tester);
await unfold(tester);
expect(find.text('78, Cross Cut Road, Gandhipuram'), findsOneWidget);
// The rung, and the outcome card that restates the last one.
expect(find.text('78, Cross Cut Road, Gandhipuram'), findsNWidgets(2));
});
testWidgets('the same street is never printed twice', (tester) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(tester, record());
await openLog(tester);
await unfold(tester);
expect(find.text('78, Cross Cut Road, Gandhipuram'), findsOneWidget);
// Once on the rung and once on the outcome card, and never a third time
// — the duplication this test was written against was a whole ROUTE
// section repeating the journey.
expect(find.text('78, Cross Cut Road, Gandhipuram'), findsNWidgets(2));
});
});
}

View File

@@ -169,10 +169,13 @@ void main() {
// the build flag still reaches it, and a profile that lies about the work
// would be worse than one nobody uses.
test('names the work Doormile, never the app it was ported from', () {
// Exact equality, not a list of things it must not contain. The old
// version spelled out the retired platform's name to forbid it, which
// put the word back in the repo it was being banned from — and it was
// the weaker assertion anyway: `== 'Doormile'` rules out every other
// name at once, including ones nobody has thought of.
const p = ServiceProfile.delivery;
expect(p.label, 'Doormile');
expect(p.label.toLowerCase(), isNot(contains('nearle')));
expect(p.label.toLowerCase(), isNot(contains('xpress')));
});
test('speaks in deliveries, not bookings', () {

View File

@@ -7,19 +7,22 @@ import 'package:miler/controllers/pickups_controller.dart';
///
/// [kGeofenceEnforced] decides whether "Picked up" means the rider was standing
/// at the door or merely pressed a button, and it has already been wrong in
/// both directions: it was `kDebugMode` — so every debug build had no fence and
/// the fence was therefore never tested — and then a hard `false`, so the flow
/// could not be walked at a desk at all.
/// three directions: it was `kDebugMode` — so every debug build had no fence
/// and the fence was therefore never tested — then a hard `false`, so the flow
/// could not be walked at a desk at all, then a define defaulting to off.
///
/// **It is currently OFF**, decided on 2026-08-19 because the fence was
/// refusing real work: riders pressing *Picked up* at a counter were told they
/// were kilometres away, most often because the booking's own coordinates were
/// wrong rather than the rider. See the declaration for the full reasoning and
/// for how to restore it.
/// **It is OFF, decided 2026-08-25.** It was turned on that morning with a
/// radius of [kGeofenceRadiusMeters] and turned off again the same day, at the
/// founder's call. Nothing was found wrong with it — this is a decision about
/// *when* to switch it on.
///
/// So this file no longer guards a direction. It guards the two properties that
/// made the old defaults dangerous: that the state is *deliberate*, and that
/// every gate reads the same switch.
/// So the direction this file guards has flipped twice in a day, which is
/// exactly the pattern that made the old defaults dangerous, and it is why the
/// three tests below are not all about the direction. The measurement work is
/// unchanged and still asserted: a `best` fix with a shelf life, the phone's
/// error credited to the rider, and one radius rather than three. All of it is
/// dormant while [kGeofenceEnforced] is false and none of it needs revisiting
/// on the day it goes back on.
void main() {
test('there is one switch, and the legacy name derives from it', () {
// Two constants that could disagree is exactly how a build ends up with
@@ -33,8 +36,8 @@ void main() {
// If this fails, one of two things happened, and both should be read rather
// than silenced:
//
// • the default was changed back to enforced — which is the intended
// end state, so update this test with the date and the reason; or
// • the default was turned back on — record the date and the reason here,
// as the previous flips did; or
// • this run passed `--dart-define=ENFORCE_GEOFENCE=true`, in which case
// the failure is correct and is telling you this build has the fence on.
expect(
@@ -46,4 +49,19 @@ void main() {
'--dart-define=ENFORCE_GEOFENCE=true.',
);
});
test('the radius is one number, and it is the tight one', () {
// Asserted while the fence is off, deliberately. The app used to hold
// three — a configured `pickupradius` defaulting to 100 in the controller,
// a hardcoded 500 in Home's bulk gate, and no agreement between them — so
// which fence a rider met depended on whether he ticked boxes or slid a
// sheet. Whichever way the switch above is set, that must not come back.
expect(kGeofenceRadiusMeters, 10);
});
test('a cached fix has a shelf life', () {
// On a round, a stale position is reliably the *previous* stop. Anything
// much longer than this and the fence starts measuring from the last door.
expect(kGeofenceFixMaxAge, const Duration(seconds: 30));
});
}

View File

@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/home/route_timeline.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/home/trip_card.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// `Accepted 1/10`, NOT `Accepted 1/63`
///
/// The kitchen card's state chip is a fraction: how many of this counter's
/// orders the rider has taken, out of how many there are. The numerator was
/// right and the denominator was the whole day.
///
/// ── What it was counting ──
///
/// [RouteGroup.allStops] is every order the trip ever bucketed under a source,
/// and `groupSize` was its raw length. That deliberately includes orders the
/// rider has **collected** — they left the visible list but not the counter,
/// and renumbering the place under him is how a rider stops trusting a number.
///
/// It also included every order already **delivered** and every one he had
/// **declined**, which are Activity's business and not this card's. Off a
/// kitchen that supplies a rider all day that is most of the bucket: ten orders
/// in the dropdown, one accepted, and a chip reading `Accepted 1/63` — a
/// fraction whose denominator appears nowhere on the screen.
///
/// The load is now stated the way the rider would state it: **what is still in
/// the dropdown, plus what he has already taken off this counter**.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
tearDown(() {
Get.reset();
ServiceProfile.setActive(ServiceProfile.parcel);
});
/// One order off Vidhya Kitchen. [status] is what the backend calls it, which
/// is what `stopStateOf` reads to place it on the ladder.
Map<String, dynamic> order(int id, {required String status}) =>
<String, dynamic>{
'orderid': 'M-$id',
'pickupid': id,
'step': id,
'kitchenname': 'Vidhya Kitchen',
'sourceid': 'K1',
'pickupcustomer': 'Customer $id',
'pickupaddress': '9 Kitchen Street, Coimbatore',
'dropaddress': 'Door $id, Peelamedu, Coimbatore',
'orderstatus': status,
'type': 'delivery',
'pickuplat': 11.01,
'pickuplon': 76.95,
};
/// The card's own grouping, run the way the screen runs it.
List<RouteGroup> groupsFor(
List<Map<String, dynamic>> stops, {
Set<String> accepted = const {},
Set<String> collected = const {},
}) {
ServiceProfile.setActive(ServiceProfile.milkMan);
return TripCard(
trip: Trip.fromStops(id: 'T1', stops: stops),
acceptedIds: accepted,
collectedIds: collected,
).routeGroups();
}
group('the denominator is the counter, not the day', () {
test('ten in the dropdown and one accepted reads 1 of 10', () {
// The reported case, at the size it was reported: one kitchen, ten
// orders still to decide, and fifty-three already delivered today.
final stops = <Map<String, dynamic>>[
for (var i = 1; i <= 10; i++) order(i, status: 'assigned'),
for (var i = 11; i <= 63; i++) order(i, status: 'delivered'),
];
final g = groupsFor(stops, accepted: {'M-1'}).single;
expect(g.stops.length, 10, reason: 'the dropdown holds ten');
expect(g.allStops.length, 63, reason: 'the bucket still holds the day');
expect(g.onRungCount, 1);
expect(
g.groupSize,
10,
reason: 'fifty-three delivered orders are Activity\'s, not this card\'s',
);
});
test('a collected order keeps its place in the arithmetic', () {
// The original rationale, which must survive the fix: three bags are in
// the rider's box and off Home, but they came off this counter and the
// place still holds six.
final stops = <Map<String, dynamic>>[
for (var i = 1; i <= 6; i++) order(i, status: 'assigned'),
];
final g = groupsFor(
stops,
accepted: {'M-1', 'M-2', 'M-3'},
collected: {'M-1', 'M-2', 'M-3'},
).single;
expect(g.stops.length, 3, reason: 'the collected three left Home');
expect(
g.groupSize,
6,
reason: 'renumbering the counter under him is the bug this prevents',
);
});
test('a declined order drops out of the load', () {
final stops = <Map<String, dynamic>>[
for (var i = 1; i <= 4; i++) order(i, status: 'assigned'),
order(5, status: 'rejected'),
];
final g = groupsFor(stops).single;
expect(g.groupSize, 4, reason: 'he has finished with the declined one');
});
test('nothing delivered means the two counts agree', () {
final stops = <Map<String, dynamic>>[
for (var i = 1; i <= 5; i++) order(i, status: 'assigned'),
];
final g = groupsFor(stops).single;
expect(g.groupSize, 5);
expect(g.groupSize, g.allStops.length);
});
test('a caller that tracks no states gets the old arithmetic', () {
// Better to over-count than to invent a denominator: a hand-built group
// with no `allStopStates` must not silently report zero.
const g = RouteGroup(
key: 'K1',
name: 'Vidhya Kitchen',
stops: [],
allStops: [{}, {}, {}],
firstIndex: 0,
state: StopState.pending,
stopStates: [],
);
expect(g.groupSize, 3);
});
});
group('the chip on the card says the same number', () {
Future<void> pump(
WidgetTester tester,
List<Map<String, dynamic>> stops, {
Set<String> accepted = const {},
}) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
tester.view.physicalSize = const Size(390 * 3, 1600 * 3);
tester.view.devicePixelRatio = 3;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: TripCard(
trip: Trip.fromStops(id: 'T1', stops: stops),
acceptedIds: accepted,
onToggleSelect: (_) {},
),
),
),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('the accepted chip is a fraction of the dropdown', (
tester,
) async {
await pump(tester, [
for (var i = 1; i <= 10; i++) order(i, status: 'assigned'),
for (var i = 11; i <= 63; i++) order(i, status: 'delivered'),
], accepted: {'M-1'});
expect(find.text('Accepted 1/10'), findsOneWidget);
expect(
find.text('Accepted 1/63'),
findsNothing,
reason: 'the day is not the counter',
);
});
testWidgets('a single-order place states no fraction at all', (
tester,
) async {
// `Accepted (1 of 1)` is noise, and the guard for it reads `groupSize`.
await pump(
tester,
[order(1, status: 'assigned')],
accepted: {'M-1'},
);
expect(find.text('Accepted'), findsOneWidget);
expect(find.textContaining('Accepted 1/'), findsNothing);
});
});
}

View File

@@ -0,0 +1,149 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE BRIEF DESCRIBES WHAT IS LEFT
///
/// Home's brief card carries four figures — duration, distance, parcels,
/// payment — under a headline that counts down (`20 stops left`).
///
/// The headline moved as the rider worked. The figures did not. They were
/// counted once over every stop the slot ever held, so a rider with five
/// collections to go read `≈4h 15m · 29.0 km · 25 parcels`: the morning he
/// started with, including the twenty already in his box and gone to
/// Deliveries. The one question the card exists to answer — *what is in front
/// of me* — was the one thing on it that was stale.
///
/// [Trip.outstanding] is the recount. The line it draws is
/// `WorkBoundary.pickupComplete`, the same boundary Home and Deliveries split
/// on, so a stop cannot be counted here and owned there.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
Map<String, dynamic> stop(
String id, {
String status = 'accepted',
int parcels = 1,
double lat = 11.0,
double lng = 76.95,
double cash = 0,
}) => {
'orderid': id,
'pickupid': id,
'orderstatus': status,
'pickupquantity': parcels,
'pickuplat': lat,
'pickuplon': lng,
'collectionamt': cash,
};
group('what the figures count', () {
test('a collected stop drops out of every figure', () {
final trip = Trip.fromStops(
id: 't1',
stops: [
stop('A', status: 'picked', lat: 11.00, lng: 76.90),
stop('B', lat: 11.05, lng: 76.95),
stop('C', lat: 11.10, lng: 77.00),
],
hubLat: 11.0,
hubLng: 76.95,
);
final left = trip.outstanding(originLat: 11.0, originLng: 76.95);
expect(trip.stopCount, 3, reason: 'the trip itself is untouched');
expect(left.stopCount, 2);
expect(left.totalParcels, lessThan(trip.totalParcels));
expect(left.totalDuration, lessThan(trip.totalDuration));
expect(left.routeMeters, lessThan(trip.routeMeters));
});
test('the local collected record counts, not just the status', () {
// The queue is a poll behind, so a stop the rider has just handed over
// still reports its old status. `collectedIds` is what closes that
// window — the same set Home writes after pickup-complete returns.
final trip = Trip.fromStops(
id: 't1',
stops: [stop('A'), stop('B')],
);
expect(trip.outstanding().stopCount, 2, reason: 'nothing collected yet');
expect(trip.outstanding(collectedIds: const {'A'}).stopCount, 1);
});
test('nothing collected returns the very same object', () {
// The common case — a rider who has not started — must not pay for a
// rebuild of the whole trip on every frame of the brief.
final trip = Trip.fromStops(id: 't1', stops: [stop('A'), stop('B')]);
expect(identical(trip.outstanding(), trip), isTrue);
});
test('skipped and cancelled work is not still-to-do', () {
final trip = Trip.fromStops(
id: 't1',
stops: [
stop('A', status: 'skipped'),
stop('B', status: 'cancelled'),
stop('C'),
],
);
expect(
trip.outstanding().stopCount,
1,
reason: 'a written-off stop is a record, not a stop to plan around',
);
});
test('a run with everything collected leaves nothing to describe', () {
final trip = Trip.fromStops(
id: 't1',
stops: [
stop('A', status: 'picked'),
stop('B', status: 'picked'),
],
);
final left = trip.outstanding();
expect(left.stopCount, 0);
expect(left.totalParcels, 0);
expect(
left.totalDuration,
Duration.zero,
reason: 'the four cells fall back to the em dash, not to a stale figure',
);
});
});
group('payment on a meal run', () {
test('is nothing to collect, whatever the payload says', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
final trip = Trip.fromStops(
id: 't1',
stops: [stop('A', cash: 250), stop('B', cash: 130)],
);
// The cell prints the em dash when this is zero — the rider on a meal
// run is never asked for money, so a figure here would be a prompt to
// collect one.
expect(trip.outstanding().cashToCollect, 0);
});
test('but a parcel route still counts the cash it is owed', () {
ServiceProfile.setActive(ServiceProfile.parcel);
final trip = Trip.fromStops(
id: 't1',
stops: [
stop('A', status: 'picked', cash: 250),
stop('B', cash: 130),
],
);
expect(
trip.outstanding().cashToCollect,
130,
reason: 'the collected stop\'s cash is no longer waiting to be taken',
);
});
});
}

View File

@@ -109,18 +109,28 @@ void main() {
}
});
testWidgets('the select-all label sits on the name axis', (tester) async {
testWidgets('the select-all closes the row it belongs to', (tester) async {
await pumpHome(tester);
// The regression this file exists for. It heads the list it selects, so
// it starts where the list starts — not one rail further in, and not two
// points off it either.
final label = tester.getRect(find.text('Select all 3')).left;
final name = tester.getRect(find.text('Customer 1')).left;
// ── The axis it answers to is the right one now ──
//
// This pinned the label to the *name* axis, when the control headed its
// list from the left. Inside a group it is a control over a list rather
// than a heading for one, so it sits on the edge a list's own controls
// use — the same right edge the bag labels and the chevrons below it
// already keep. What must not happen is that it floats between the two.
final label = tester.getRect(find.text('Select all 3')).right;
final list = tester.getRect(find.byType(RouteTimeline)).right;
final bag = tester.getRect(find.text('Bag 1')).right;
expect(
label,
closeTo(name, 0.5),
reason: 'the select-all is $label, the names are $name',
closeTo(list, 4),
reason: 'the select-all ends at $label, the list at $list',
);
expect(
label,
greaterThan(bag),
reason: 'and it closes past the bag column rather than inside it',
);
});

View File

@@ -1,4 +1,5 @@
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:get/get.dart';
@@ -98,11 +99,20 @@ void main() {
) async {
await pump(tester);
// The brief's first ink is the hero numeral now — the caption sits to
// its right — so the claim is measured on the numeral.
// ── Measured on the metrics, not on the headline ──
//
// The headline is centred now: the card is symmetrical — a title over a
// full-width bar — and a left-aligned headline over a centred bar reads as
// two elements that failed to line up. So the brief's left edge is the one
// its *left-aligned* content sits on, which is the metric grid under the
// bar. That is the column the route below has to agree with.
// The grid's first ink is the icon tile, not the word beside it.
final brief = tester.getRect(
find
.descendant(of: find.byType(TripBriefStrip), matching: find.text('2'))
.ancestor(
of: find.byIcon(LucideIcons.clock),
matching: find.byType(Container),
)
.first,
);
final route = tester.getRect(find.text('TODAY’S RUN'));

418
test/home_header_test.dart Normal file
View File

@@ -0,0 +1,418 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/home_header.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE HOME HEADER
///
/// Two things this file is for, and the second is the one that will save
/// somebody later.
///
/// **The budget.** The header's whole justification is that the rider's first
/// stop was below the fold. That is an arithmetic claim, so it is measured
/// here: expanded content stays inside 150–175dp, and the collapsed state is
/// 52. A header that creeps back up a line at a time is exactly how the
/// original one got to 160.
///
/// **The extent contract.** A `SliverPersistentHeader` declares a fixed height
/// and anything longer than it is *silently clipped* — no exception, no
/// overflow stripe, just a trip selector with its bottom cut off. Two of the
/// lines in here are type that grows with the system font, so the extent is
/// derived rather than hardcoded, and these tests walk it from 1.0× to 2.0× on
/// four screen widths to prove it is never short.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
/// Pumps the header as the page does: a pinned sliver at the head of a
/// `CustomScrollView`, with something long under it to scroll.
Future<ScrollController> pump(
WidgetTester tester, {
String greeting = 'Good afternoon, Rajan',
String subtitle = '17 stops remaining',
String collapsed = 'Trip 1 · 17 left · 33/50',
bool onDuty = true,
bool locked = false,
bool busy = false,
VoidCallback? onToggle,
double width = 390,
double textScale = 1.0,
double topInset = 47,
}) async {
final controller = ScrollController();
addTearDown(controller.dispose);
tester.view.physicalSize = Size(width * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
debugShowCheckedModeBanner: false,
home: MediaQuery(
data: MediaQueryData(
size: Size(width, 844),
padding: EdgeInsets.only(top: topInset),
textScaler: TextScaler.linear(textScale),
),
child: Builder(
builder: (context) => Scaffold(
body: CustomScrollView(
controller: controller,
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: HomeHeaderDelegate(
topInset: topInset,
expandedContent: HomeHeaderDelegate.expandedContentFor(
context,
),
greeting: greeting,
subtitle: subtitle,
onDuty: onDuty,
dutyLocked: locked,
dutyBusy: busy,
onToggleDuty: onToggle ?? () {},
collapsedSummary: collapsed,
),
),
SliverList.list(
children: [
for (var i = 0; i < 40; i++)
SizedBox(height: 60, child: Text('row $i')),
],
),
],
),
),
),
),
),
),
);
await tester.pump();
return controller;
}
group('1 · the height budget', () {
testWidgets('expanded content is inside 95–120dp', (tester) async {
late double content;
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Builder(
builder: (context) {
content = HomeHeaderDelegate.expandedContentFor(context);
return const SizedBox();
},
),
),
),
);
// ── The budget moved when the tabs did ──
//
// It was 150–175, for a header that carried the trip selector. The
// selector sits on the page under it now, so what is left up here is a
// wordmark row, a greeting and one operational line — and the budget is
// the sum of those rather than a number inherited from the old shape.
//
// The tabs are still charged, just not to this. Header + tabs together
// are about 124 + 46 + their padding, against the 160 the fixed block
// cost before — and unlike that block, 72 of it scrolls away.
// ── The budget moved again when the duplicate line went ──
//
// It was 110–135 with `N stops remaining` under the greeting. That line
// was the brief card's headline said twice, forty points apart, smaller
// and greyer the first time — so it is gone and the header is ~20 points
// cheaper. Every one of those points is the rider's first stop moving up
// the screen, which is the whole reason this test exists.
expect(
content,
inInclusiveRange(95, 120),
reason:
'measured $content — the header exists to get the first stop above '
'the fold, and every point it grows takes one back',
);
});
testWidgets('collapsed content is 52dp', (tester) async {
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => const MaterialApp(home: SizedBox()),
),
);
expect(HomeHeaderDelegate.collapsedContent, closeTo(52, 0.5));
});
});
group('2 · the extent is never short', () {
for (final width in const [320.0, 360.0, 390.0, 430.0]) {
for (final scale in const [1.0, 1.3, 1.5, 2.0]) {
testWidgets('${width.toInt()}dp at ${scale}x', (tester) async {
await pump(tester, width: width, textScale: scale);
// A clipped sliver throws nothing, so the assertion that matters is
// that the layout itself did not complain — an overflowing Column
// inside the header WOULD throw, and that is the failure mode these
// widths and scales are hunting.
expect(tester.takeException(), isNull);
expect(find.text('ON'), findsOneWidget);
});
}
}
testWidgets('a long rider name does not push anything out', (tester) async {
await pump(
tester,
width: 320,
greeting: 'Good afternoon, Venkateswara Subramaniam',
);
expect(tester.takeException(), isNull);
// The greeting ellipsises to one line rather than wrapping and pushing
// the header past its declared extent.
expect(find.text('Good afternoon, Venkateswara Subramaniam'),
findsOneWidget);
});
});
group('3 · what each state says', () {
testWidgets('off duty reads OFF and keeps the same shape', (tester) async {
await pump(tester, onDuty: false);
expect(find.text('OFF'), findsOneWidget);
expect(find.text('ON'), findsNothing);
});
testWidgets('the stop count is NOT repeated up here', (tester) async {
// ── It was said twice, forty points apart ──
//
// `17 stops remaining` sat under the greeting in grey 13pt, and the
// brief card immediately below leads with `17 stops left` as its
// headline at 17pt against a progress bar. Two sizes, two wordings, one
// figure — and the smaller, greyer, vaguer one came first.
//
// The header carries identity and duty. The count belongs to the card
// that also shows the duration, distance and parcels behind it.
await pump(tester);
expect(find.text('Good afternoon, Rajan'), findsOneWidget);
expect(find.text('17 stops remaining'), findsNothing);
expect(find.textContaining('stops remaining'), findsNothing);
});
testWidgets('but the collapsed line still carries it', (tester) async {
// The one place the figure is still the header's job: collapsed, the
// brief card has scrolled away and this is all that is left on screen.
final c = await pump(tester);
c.jumpTo(400);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Trip 1 · 17 left · 33/50'), findsOneWidget);
});
});
group('4 · duty', () {
testWidgets('the tap target clears 48dp even though the pill is smaller', (
tester,
) async {
await pump(tester);
final target = tester.getSize(find.byType(DutyPill));
expect(target.height, greaterThanOrEqualTo(DutyPill.tapTarget));
});
testWidgets('tapping asks the page, it does not decide', (tester) async {
var asked = 0;
await pump(tester, onToggle: () => asked++);
await tester.tap(find.byType(DutyPill));
expect(asked, 1);
});
testWidgets('a write in flight refuses a second press', (tester) async {
var asked = 0;
await pump(tester, busy: true, onToggle: () => asked++);
await tester.tap(find.byType(DutyPill), warnIfMissed: false);
expect(asked, 0, reason: 'two duty writes for one intent');
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('locked mid-pickup refuses, and says so', (tester) async {
var asked = 0;
await pump(tester, locked: true, onToggle: () => asked++);
await tester.tap(find.byType(DutyPill), warnIfMissed: false);
expect(asked, 0);
// The state is still readable — the control dims rather than vanishing.
expect(find.text('ON'), findsOneWidget);
});
});
group('5 · collapsing', () {
testWidgets('the greeting goes and the operational line arrives', (
tester,
) async {
final c = await pump(tester);
expect(find.text('Good afternoon, Rajan'), findsOneWidget);
expect(find.text('Trip 1 · 17 left · 33/50'), findsNothing);
c.jumpTo(400);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
// Each layout is dropped entirely once its side of the cross-fade
// reaches zero, rather than kept at `Opacity(0)`. A fully transparent
// subtree still lays out and still paints into a saved layer on every
// frame of a scroll, and this one holds a wordmark, a greeting and a
// duty control — which is a lot of nothing to composite forty times a
// second.
expect(find.text('Good afternoon, Rajan'), findsNothing);
expect(find.text('Trip 1 · 17 left · 33/50'), findsOneWidget);
});
testWidgets('duty stays reachable in the collapsed state', (tester) async {
var asked = 0;
final c = await pump(tester, onToggle: () => asked++);
c.jumpTo(400);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
// The expanded copy is gone by now, so this can only be the collapsed
// one — which is the point: duty is the one control that must never
// scroll away.
expect(find.byType(DutyPill), findsOneWidget);
await tester.tap(find.byType(DutyPill));
expect(asked, 1);
});
testWidgets('the header holds the top of the screen', (tester) async {
final c = await pump(tester);
c.jumpTo(600);
await tester.pump();
// The pinned extent is exactly the collapsed one plus the inset.
final material = tester.getSize(
find.byKey(HomeHeaderDelegate.surfaceKey),
);
expect(
material.height,
closeTo(47 + HomeHeaderDelegate.collapsedContent, 1),
);
});
});
group('6 · rebuild scope', () {
test('an unrelated change does not rebuild the header', () {
HomeHeaderDelegate make({String subtitle = '17 stops remaining'}) =>
HomeHeaderDelegate(
topInset: 47,
expandedContent: 170,
greeting: 'Good afternoon, Rajan',
subtitle: subtitle,
onDuty: true,
dutyLocked: false,
dutyBusy: false,
onToggleDuty: () {},
collapsedSummary: 'Trip 1 · 17 left · 33/50',
);
// Same inputs — a row further down the list changing touches none of
// them, so the sliver does not repaint for it.
expect(make().shouldRebuild(make()), isFalse);
// A figure it actually draws does.
expect(
make().shouldRebuild(make(subtitle: '16 stops remaining')),
isTrue,
);
});
});
// ══════════════════════════════════════════════════════════════════════
// THE COLLAPSE ITSELF
//
// The budget tests above measure the header at its two REST states. The bug
// this group exists for lived entirely in between them.
//
// `_ExpandedBody` was a plain child of a `StackFit.expand` stack, so it was
// handed the header's *current* height — which, mid-collapse, is less than
// the content. Its Column cannot compress, so from the first pixel of scroll
// Flutter drew `BOTTOM OVERFLOWED BY 28 PIXELS` in yellow and black across
// the top of Home, over the greeting, on a rider's phone.
//
// Neither rest state shows it: fully expanded there is room, fully collapsed
// the body has faded to nothing. Only the travel between them does — which
// is why every assertion below walks the scroll rather than sampling its
// ends.
// ══════════════════════════════════════════════════════════════════════
group('7 · nothing overflows while it collapses', () {
/// Every offset from rest to fully pinned, finely enough that no fade
/// threshold (0.55 / 0.6) can be stepped over.
Future<void> walk(
WidgetTester tester,
ScrollController controller,
double range,
) async {
for (var i = 0; i <= 40; i++) {
controller.jumpTo(range * i / 40);
await tester.pump();
expect(
tester.takeException(),
isNull,
reason:
'overflowed at scroll offset ${range * i / 40} of $range — the '
'header must clip its content, never squeeze it',
);
}
}
testWidgets('at 1.0x on a 390pt phone', (tester) async {
final c = await pump(tester);
await walk(tester, c, 200);
});
testWidgets('at every text scale a rider can set', (tester) async {
for (final scale in const [1.0, 1.3, 1.5, 2.0]) {
final c = await pump(tester, textScale: scale);
await walk(tester, c, 200);
}
});
testWidgets('on a narrow phone, where the greeting wraps', (tester) async {
// 320pt with a long name is where the greeting is most likely to take a
// second line and push the Column past the extent.
final c = await pump(
tester,
width: 320,
greeting: 'Good afternoon, Rajanikanth Subramaniam',
subtitle: '20 stops remaining',
);
await walk(tester, c, 200);
});
testWidgets('the expanded body keeps its full height as it goes', (
tester,
) async {
// The fix, stated as behaviour rather than as a widget choice: the
// greeting does not shrink on the way out, it slides out. If it ever
// reflows again, this catches it before the overflow banner does.
final c = await pump(tester);
final full = tester.getSize(find.text('Good afternoon, Rajan')).height;
c.jumpTo(30);
await tester.pump();
expect(tester.takeException(), isNull);
expect(
tester.getSize(find.text('Good afternoon, Rajan')).height,
full,
reason: 'the body is measured at its own height, not the header\'s',
);
});
});
}

View File

@@ -134,11 +134,30 @@ void main() {
anyOf(isNull, isEmpty),
reason: 'a shadow lifts a group off the page; the spine groups it',
);
expect(
d.border,
isNull,
reason: 'an outline around a group is the card by another name',
);
// ── A rule between rows is not an outline around them ──
//
// This required `border` to be null outright, which caught the thing
// it was written for — a rectangle drawn round a kitchen — and also
// catches a one-sided hairline separating one order from the next.
// Those are opposites: a box groups by enclosing, a rule separates by
// dividing, and the second is the spine's own grammar rather than a
// competitor to it. What stays banned is an enclosure.
final border = d.border;
if (border is Border) {
expect(
border.left.style == BorderStyle.none &&
border.right.style == BorderStyle.none &&
border.top.style == BorderStyle.none,
isTrue,
reason: 'an outline around a group is the card by another name',
);
} else {
expect(
border,
isNull,
reason: 'an outline around a group is the card by another name',
);
}
}
// ── What a fill may be here, since the route moved onto a panel ──

View File

@@ -5,12 +5,31 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/helpers/constants/app_theme.dart';
import 'package:miler/views/introscreens/introscreen.dart';
/// The first-run slides. The illustrations are miniature mock-ups of the real
/// Home screen rather than a centred Material icon, which means they are made of
/// fixed-width rows — the hub rail on slide 1 is a dozen sized children in a
/// single unflexed `Row`. That is exactly the shape that overflows on a narrow
/// phone, so every slide is laid out at the narrowest screen the app supports.
/// ─────────────────────────────────────────────────────────────────────────
/// THE FIRST-RUN SLIDES
///
/// These used to be miniature mock-ups of Home built out of fixed-width rows —
/// the hub rail on slide 1 was a dozen sized children in a single unflexed
/// `Row` — and the tests existed because that is exactly the shape which
/// overflows on a narrow phone.
///
/// The slides are finished posters now: headline, sub-copy, product shot and
/// the MILER lockup are all baked into the artwork, and the screen draws no
/// text of its own. A `BoxFit.cover` image cannot overflow, so the width sweep
/// is no longer about the illustrations — it is about the controls laid over
/// them, which are the only Flutter-built objects left on the screen.
///
/// What is worth pinning instead is the part a wrong asset path would break
/// silently: three specific images, in the order the rider is meant to see
/// them, with the CTA changing on the last one.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
const slides = <String>[
'assets/images/onboard_1.png',
'assets/images/onboard_2.png',
'assets/images/onboard_3.png',
];
Future<void> pump(WidgetTester tester, {required Size screen}) async {
tester.view.physicalSize = Size(screen.width * 3, screen.height * 3);
tester.view.devicePixelRatio = 3.0;
@@ -27,6 +46,18 @@ void main() {
await tester.pumpAndSettle();
}
/// The asset the slide currently on screen is drawing.
String? shownAsset(WidgetTester tester) {
for (final image in tester.widgetList<Image>(find.byType(Image))) {
final provider = image.image;
if (provider is AssetImage &&
provider.assetName.startsWith('assets/images/onboard_')) {
return provider.assetName;
}
}
return null;
}
for (final size in const [
Size(320, 640), // smallest Android still in the fleet
Size(360, 740),
@@ -39,45 +70,104 @@ void main() {
await pump(tester, screen: size);
expect(tester.takeException(), isNull);
// Slide 1 — the slot, its load, and the round trip.
expect(find.text('Your route arrives ready'), findsOneWidget);
expect(find.text('12:00 PM – 3:00 PM'), findsOneWidget);
expect(shownAsset(tester), slides[0]);
expect(find.text('Next'), findsOneWidget);
expect(find.text('Skip'), findsOneWidget);
// A near-full-width drag, so the page always snaps to the next one. A
// velocity fling does not reliably cross the threshold here, because each
// slide hosts its own vertical scroll view and the two share the arena.
// velocity fling does not reliably cross the threshold here.
final swipe = Offset(-size.width * 0.9, 0);
await tester.drag(find.byType(PageView), swipe);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Work the stops in order'), findsOneWidget);
expect(find.text('Skipped'), findsOneWidget);
expect(shownAsset(tester), slides[1]);
expect(find.text('Next'), findsOneWidget);
await tester.drag(find.byType(PageView), swipe);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Collect, then close the loop'), findsOneWidget);
expect(find.text('₹340 cash'), findsOneWidget);
expect(shownAsset(tester), slides[2]);
// Last slide swaps the CTA and drops Skip.
// Last slide swaps the CTA and drops Skip — an invisible-but-present
// control is a trap for screen readers, so it is unmounted rather than
// faded to `opacity: 0`.
expect(find.text('Get started'), findsOneWidget);
expect(find.text('Next'), findsNothing);
expect(find.text('Skip'), findsNothing);
});
}
testWidgets('the three posters are in the order they were given', (
tester,
) async {
await pump(tester, screen: const Size(390, 844));
// `PageView.builder` only builds the page on screen, so the order has to be
// walked rather than read out of the tree in one go. Which is the honest
// test anyway: what matters is the sequence the rider is shown, and 1-3-2
// would pass any assertion made against a set.
final seen = <String?>[shownAsset(tester)];
for (var i = 1; i < slides.length; i++) {
await tester.drag(find.byType(PageView), const Offset(-351, 0));
await tester.pumpAndSettle();
seen.add(shownAsset(tester));
}
expect(seen, slides);
});
testWidgets('the artwork runs edge to edge', (tester) async {
await pump(tester, screen: const Size(390, 844));
final image = tester.widget<Image>(
find
.byWidgetPredicate(
(w) =>
w is Image &&
w.image is AssetImage &&
(w.image as AssetImage).assetName == slides[0],
)
.first,
);
// `cover` is what makes a 940×1672 poster fill a phone of any aspect
// without letterboxing. `contain` would put bars down the sides, which on
// artwork that ends in a red wave reads as a rendering fault.
expect(image.fit, BoxFit.cover);
});
testWidgets('the screen adds no captions of its own', (tester) async {
await pump(tester, screen: const Size(390, 844));
// Every slide's headline and sub-copy are baked into the image. A Flutter
// caption under the picture would say the same thing twice, in a second
// typeface, at a second size — so the only text on the screen is the two
// controls.
final texts = tester
.widgetList<Text>(find.byType(Text))
.map((t) => t.data)
.whereType<String>()
.toSet();
expect(texts, <String>{'Skip', 'Next'});
});
testWidgets('Skip is offered on the first slide', (tester) async {
await pump(tester, screen: const Size(390, 844));
expect(find.text('Skip'), findsOneWidget);
});
// The old copy pitched a gig marketplace — "pickups near you", accepted from a
// feed. This product assigns routes from a hub. Onboarding that teaches the
// wrong model is worse than none, so the wrong words are pinned out.
testWidgets('does not promise a marketplace the app does not have', (
tester,
) async {
// The old code-drawn copy pitched a gig marketplace — "pickups near you",
// accepted from a feed. This product assigns routes from a hub, and
// onboarding that teaches the wrong model is worse than none.
//
// NOTE: this can only speak for text the *app* draws. The posters carry their
// own headlines as pixels, and `onboard_1.png` does print "Powered by AI" —
// a claim the previous slides deliberately dropped because nothing in the app
// backs it. That is a copy decision about the artwork, not something a widget
// test can hold.
testWidgets('the app draws no marketplace copy of its own', (tester) async {
await pump(tester, screen: const Size(390, 844));
for (final wrong in [
'Find pickups near you',
@@ -85,11 +175,7 @@ void main() {
'Earn more, every shift',
'Powered by AI',
]) {
expect(
find.text(wrong),
findsNothing,
reason: '"$wrong" is not this app',
);
expect(find.text(wrong), findsNothing, reason: '"$wrong" is not this app');
}
});
}

View File

@@ -0,0 +1,865 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/controllers/riderkm.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/route_order.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/home/homepage.dart';
import 'package:miler/data/stop_compliance.dart';
import 'package:miler/controllers/pickups_controller.dart';
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// FOUR CONTRACTS THE APP MEETS THE BACKEND ON
///
/// Each of these has already been wrong once, and each was wrong in the same
/// way: the app read a field the server does not send, or ignored one it does.
/// None of them raised anything — an absent key and an empty answer are the
/// same value, so every failure was silent and every symptom was cosmetic
/// ("the chart is empty", "the route is in the wrong order"). That is what
/// makes them worth pinning rather than trusting.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// ══════════════════════════════════════════════════════════════════════
// 1 · ROUTE SEQUENCING
//
// The backend's rule, given 25 Aug: `sequencedat` is the authority, not
// `step`. Non-null → a route was assigned, follow `step` exactly. Null → no
// route, and nearest-first is correct rather than a fallback for a bug.
//
// `RouteOrder` implemented that correctly and it never ran, because
// `pickupFromBooking` was dropping the field. Every adapted row looked
// unsequenced and the app re-sorted routes the hub had solved.
// ══════════════════════════════════════════════════════════════════════
group('1 · sequencedat survives the adapter and decides the order', () {
Map<String, dynamic> booking(int id, Map<String, dynamic> extra) => {
'bookingid': id,
'bookingno': 'B$id',
'pickupaddress': '$id Thadagam Road, Coimbatore',
'pickupcustomer': 'Sri Balaji Stores',
...extra,
};
/// The rider is beside stop 3, so nearest-first would put it first. Any
/// test that expects the hub's order has to be able to lose to proximity
/// if the gate fails — otherwise it passes for the wrong reason.
double distanceTo(Map<String, dynamic> s) => switch ('${s['orderid']}') {
'3' => 10,
'1' => 4000,
'2' => 9000,
_ => 99999,
};
test('a sequenced booking keeps its stamp and its step', () {
final row = ApiConfig.pickupFromBooking(
booking(1, {'step': 2, 'sequencedat': '2026-08-25T09:00:00+05:30'}),
);
expect(
RouteOrder.isSequenced(row),
isTrue,
reason: 'the stamp did not survive pickupFromBooking',
);
expect(RouteOrder.sequenceOf(row), 2);
});
test('the hub order is followed, and proximity does not override it', () {
final stops = [
for (final (id, step) in const [(3, 3), (1, 1), (2, 2)])
ApiConfig.pickupFromBooking(
booking(id, {
'step': step,
'sequencedat': '2026-08-25T09:00:00+05:30',
}),
),
];
final (sorted, source) = RouteOrder.sort(stops, distanceTo: distanceTo);
expect(source, RouteOrderSource.adminSequence);
expect(
sorted.map((s) => '${s['orderid']}').toList(),
['1', '2', '3'],
reason:
'the rider is standing on B3 — if this comes back B3-first the '
'stamp was ignored and nearest-first won',
);
});
test('no stamp means no route, and the fallback is correct', () {
// The backend's own words: `step: 0` with a null stamp is a rider holding
// fewer than two active stops, or a stop with no coordinates. Neither is
// a route, and re-sorting is the right answer rather than a workaround.
final stops = [
for (final id in const [1, 2, 3])
ApiConfig.pickupFromBooking(
booking(id, {'step': 0, 'sequencedat': null}),
),
];
expect(RouteOrder.hasAdminSequence(stops), isFalse);
final (sorted, source) = RouteOrder.sort(stops, distanceTo: distanceTo);
expect(source, isNot(RouteOrderSource.adminSequence));
expect(
'${sorted.first['orderid']}',
'3',
reason: 'with no route assigned, the nearest stop leads',
);
});
test('a blank stamp is no stamp', () {
final row = ApiConfig.pickupFromBooking(
booking(1, {'step': 0, 'sequencedat': ' '}),
);
expect(RouteOrder.isSequenced(row), isFalse);
});
});
// ══════════════════════════════════════════════════════════════════════
// 2 · PER-STOP DISTANCE
//
// Three spellings, because the app writes two of them itself: the contract's
// `compliance.actualkm`, the backend's `riderkms` on a raw booking row, and
// `actualkms` — what `PickupsController` posts on the arrival and pickup
// writes, so it is the key on any record stamped locally before the queue
// caught up. Reading only the first two made a stop the rider had just
// finished report no distance at all.
// ══════════════════════════════════════════════════════════════════════
group('2 · every distance spelling resolves', () {
test('compliance.actualkm — the contract\'s own', () {
final c = StopCompliance.of({
'compliance': {'actualkm': 4.2},
});
expect(c.actualKm, 4.2);
});
test('riderkms — a row straight off the API', () {
expect(StopCompliance.of({'riderkms': 3.1}).actualKm, 3.1);
});
test('actualkms — a row this app stamped itself', () {
expect(StopCompliance.of({'actualkms': '2.75'}).actualKm, 2.75);
});
test('the contract wins when more than one is present', () {
final c = StopCompliance.of({
'compliance': {'actualkm': 9.9},
'riderkms': 3.1,
'actualkms': '2.75',
});
expect(
c.actualKm,
9.9,
reason: 'the authoritative figure must outrank the locally stamped one',
);
});
test('riderkms outranks the local stamp', () {
final c = StopCompliance.of({'riderkms': 3.1, 'actualkms': '2.75'});
expect(c.actualKm, 3.1);
});
test('no distance at all is null, not zero', () {
// Null is what the UI turns into the em dash. A zero would render as a
// real measurement of nothing, which is a different claim.
expect(StopCompliance.of(const {}).actualKm, isNull);
expect(StopCompliance.of({'riderkms': 0}).actualKm, isNull);
expect(StopCompliance.of({'actualkms': ''}).actualKm, isNull);
});
});
// ══════════════════════════════════════════════════════════════════════
// 3 · THE WEEK'S DISTANCE
//
// `GET /miler/earnings` answers with six totals for the period asked for and
// no series. The app read a `breakdown` array that is not on the contract, so
// it was always null and the chart drew an empty week while the rider had
// ridden all of it.
//
// Seven daily calls build the week now. The fast path stays, so the day the
// field ships the seven calls stop — and that switch has to be automatic and
// has to refuse anything it cannot draw.
// ══════════════════════════════════════════════════════════════════════
group('3 · weekly distance: fast path and fallback', () {
late List<Uri> asked;
/// Answers `weekly` with [weekly] and every `daily` with its own total.
setUp(() => SharedPreferences.setMockInitialValues({}));
void stub({
Map<String, dynamic> weekly = const {'total_kms': 40},
double dailyKm = 5,
}) {
asked = <Uri>[];
MilerApi.client = MockClient((req) async {
asked.add(req.url);
final period = req.url.queryParameters['period'];
final body = period == 'weekly' ? weekly : {'total_kms': dailyKm};
return http.Response(
jsonEncode({'success': true, 'data': body}),
200,
headers: {'content-type': 'application/json'},
);
});
}
int dailyCalls() =>
asked.where((u) => u.queryParameters['period'] == 'daily').length;
test(
'FAST PATH · a valid breakdown is used, and no daily call is made',
() async {
stub(
weekly: {
'total_kms': 40.5,
// The backend's own example shape: ISO dates, not weekday names.
'breakdown': [
{'day': '2026-08-19', 'kms': 18.4},
{'day': '2026-08-20', 'kms': 22.1},
],
},
);
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
final details = res['details'] as List;
expect(details, hasLength(2));
expect(res['total_kms'], 40.5);
expect(
dailyCalls(),
0,
reason: 'the seven daily calls must stop the day breakdown ships',
);
},
);
test('FAST PATH · an ISO date is labelled with the day it names', () async {
stub(
weekly: {
'total_kms': 18.4,
'breakdown': [
{'day': '2026-08-19', 'kms': 18.4},
],
},
);
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
final first = (res['details'] as List).first;
// 2026-08-19 is a Wednesday. The chart labels a bar with the first three
// characters of `day`, so an un-normalised ISO date renders as `202`.
expect(first.day, 'Wed');
expect(first.kms, 18.4);
});
test('FAST PATH · a weekday name is passed through untouched', () async {
stub(
weekly: {
'breakdown': [
{'day': 'Mon', 'kms': 3},
],
},
);
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
expect((res['details'] as List).first.day, 'Mon');
expect(dailyCalls(), 0);
});
for (final (name, bad) in <(String, Object?)>[
('absent', null),
('empty', <dynamic>[]),
('not a list', 'nope'),
('entries that are not maps', ['Mon', 'Tue']),
(
'an entry with no day',
[
{'kms': 4},
],
),
(
'an entry whose distance will not parse',
[
{'day': 'Mon', 'kms': 'lots'},
],
),
]) {
test('FALLBACK · $name breakdown falls through to seven calls', () async {
stub(weekly: {'total_kms': 35, if (bad != null) 'breakdown': bad});
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
final details = res['details'] as List;
expect(
dailyCalls(),
7,
reason: 'a series the chart cannot draw is not a series',
);
expect(details, hasLength(7));
expect(
details.every((d) => d.kms == 5),
isTrue,
reason: 'each bar is its own day\'s total_kms',
);
});
}
test(
'FALLBACK · the daily series is labelled and ordered oldest first',
() async {
stub();
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
final days = (res['details'] as List).map((d) => d.day).toList();
expect(days, hasLength(7));
expect(days.toSet(), hasLength(7), reason: 'seven distinct weekdays');
expect(
days.last,
const [
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
'Sun',
][DateTime.now().weekday - 1],
reason: 'the last bar is today',
);
},
);
test('FALLBACK · the server\'s weekly total is not overruled', () async {
// Seven daily figures are a reconstruction. The weekly number the backend
// computed is the one that stands.
stub(weekly: {'total_kms': 99}, dailyKm: 5);
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
expect(res['total_kms'], 99);
});
test(
'FALLBACK · with no weekly total, the reconstruction stands in',
() async {
stub(weekly: {'total_kms': 0}, dailyKm: 5);
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
expect(res['total_kms'], 35);
},
);
});
// ══════════════════════════════════════════════════════════════════════
// 3b · THE TWO LIFECYCLE WRITES ACTUALLY LEAVE THE PHONE
//
// The defect these pin: the map sheet's **I've arrived** control never called
// the arrival API. `updateArrivedStatus` was reachable from `homepage.dart`
// and nowhere else, so on the logistics line — where the map sheet is the
// only arrival control — `reached` never fired, the local record was never
// written, and the rung went from ACCEPTED straight to whatever came next.
//
// Nothing raised. A transition that is never attempted looks exactly like one
// that succeeded and was not reflected, which is why this was read as a
// status-mapping bug for as long as it was.
//
// These assert the wire, not the widget: the endpoint, the identifier in the
// path, and the body — with the geofence bypass ON, which is the state the
// build ships in and the thing that was suspected of eating the call.
// ══════════════════════════════════════════════════════════════════════
group('3b · the arrival and pickup writes reach the network', () {
late List<http.Request> sent;
void stub() {
sent = <http.Request>[];
MilerApi.client = MockClient((req) async {
sent.add(req);
return http.Response(
jsonEncode({'success': true, 'data': {}}),
200,
headers: {'content-type': 'application/json'},
);
});
}
setUp(() {
SharedPreferences.setMockInitialValues({});
stub();
});
tearDown(() => MilerApi.client = http.Client());
http.Request only(String fragment) {
final hits = sent.where((r) => r.url.path.contains(fragment)).toList();
expect(
hits,
hasLength(1),
reason:
'expected exactly one request to …/$fragment, sent: '
'${sent.map((r) => r.url.path).toList()}',
);
return hits.single;
}
test('ARRIVED · endpoint, path id and body', () async {
await MilerApi.reached(4211, lat: 11.0168, lon: 76.9558);
final req = only('reached');
expect(req.method, 'POST');
// The **booking** id, in the path. Not the assignment id — that one keys
// accept/reject only — and not a consignment, which does not exist yet.
expect(req.url.path, endsWith('/miler/bookings/4211/reached'));
expect(jsonDecode(req.body), {'latitude': 11.0168, 'longitude': 76.9558});
});
test(
'ARRIVED · a fix-less arrival still posts, with no coordinates',
() async {
// The body omits what it does not have rather than sending zeros, which
// would be a claim about where the rider was standing.
await MilerApi.reached(4211);
expect(jsonDecode(only('reached').body), isEmpty);
},
);
test('PICKED · endpoint, path id and body', () async {
await MilerApi.pickupComplete(4211, lat: 11.0168, lon: 76.9558);
final req = only('pickup-complete');
expect(req.method, 'POST');
expect(req.url.path, endsWith('/miler/bookings/4211/pickup-complete'));
expect(jsonDecode(req.body), {'latitude': 11.0168, 'longitude': 76.9558});
expect(
req.headers['Idempotency-Key'],
isNotNull,
reason:
'the pivot is the one write a dropped acknowledgement must not '
'be able to run twice',
);
});
test(
'the provider routes ARRIVED to reached, not to anything else',
() async {
await UpdatePickupProvider().updatePickup({
'pickupid': 4211,
'orderheaderid': 77,
'orderstatus': 'arrived',
'riderslat': '11.016800',
'riderslon': '76.955800',
});
final req = only('reached');
// `pickupid` IS the booking id — see `ApiConfig.pickupFromBooking`, which
// maps `bookingid` onto it. This pins that, because the provider picks
// the path id off the payload and a change there would be silent.
expect(req.url.path, endsWith('/miler/bookings/4211/reached'));
expect(jsonDecode(req.body), {
'latitude': 11.0168,
'longitude': 76.9558,
});
},
);
test('the provider routes PICKED to pickup-complete', () async {
await UpdatePickupProvider().updatePickup({
'pickupid': 4211,
'orderheaderid': 77,
'orderstatus': 'picked',
'riderslat': '11.016800',
'riderslon': '76.955800',
});
expect(
only('pickup-complete').url.path,
endsWith('/miler/bookings/4211/pickup-complete'),
);
});
test(
'a missing booking id refuses the call, it does not post to zero',
() async {
// ── The silent one ──
//
// The provider resolved its path id with `pickupid ?? orderheaderid`, and
// `??` catches only null. Every caller builds the payload with
// `int.tryParse(...) ?? 0`, so a row without a booking id arrived as
// **0** — not null — the fallback never fired, and the app posted to
// `/miler/bookings/0/reached`.
//
// Nothing complained on the phone: arrival is the rider's own report and
// is deliberately not gated on the write landing, so his rung advanced
// and the row looked healthy. The hub was told about booking zero, and
// the console showed no arrival — which is exactly how this was reported.
final res = await UpdatePickupProvider().updatePickup({
'pickupid': 0,
'orderheaderid': 0,
'orderstatus': 'arrived',
'riderslat': '11.016800',
'riderslon': '76.955800',
});
expect(sent, isEmpty, reason: 'booking 0 is not a booking');
expect(res?['status'], isFalse);
},
);
test(
'a real id still posts, and zero on the other key is ignored',
() async {
await UpdatePickupProvider().updatePickup({
'pickupid': 0,
'orderheaderid': 77,
'orderstatus': 'arrived',
'riderslat': '11.016800',
'riderslon': '76.955800',
});
expect(
only('reached').url.path,
endsWith('/miler/bookings/77/reached'),
);
},
);
test('the geofence bypass does not stop the write leaving', () async {
// The suspicion this rules out. `_checkGeofence` returns true early when
// enforcement is off and its success branch contains nothing else — so
// the bypass skips the distance *rejection* and nothing more.
//
// Asserted through the provider rather than by reading the flag, because
// "the request went out with the fence off" is the claim, and the flag is
// only evidence for it.
expect(kBypassGeofenceForTesting, isTrue);
await UpdatePickupProvider().updatePickup({
'pickupid': 4211,
'orderstatus': 'arrived',
'riderslat': '11.016800',
'riderslon': '76.955800',
});
expect(sent, isNotEmpty, reason: 'the bypass swallowed the request');
expect(only('reached').url.path, contains('/4211/reached'));
});
});
// ══════════════════════════════════════════════════════════════════════
// 3c · THE PICKUP MILESTONE IS NOT THE DELIVERY STATUS
//
// The backend's booking lifecycle, confirmed by them and with no Arrived rung
// anywhere in it:
//
// pending → miler_assigned → pickup_scheduled → converted_to_consignment
// → out_for_delivery / active → delivered
//
// Three of those words carry the **pickup** milestone and all three parsed as
// `unknown`: `pickup_scheduled`, `converted_to_consignment` and `picked_up`.
// So the one field the pickup UI reads had nothing in it, and `active` — a
// *delivery* state, which does parse — won by default. That is the second
// half of "Picked shows as Active"; the first was the app writing `active`
// onto the rung itself.
//
// Nothing about the backend's meanings is changed here. `active` still means
// active, and the delivery surfaces still read it that way. What is fixed is
// that the pickup surfaces resolve the *pickup* fact and it outranks a
// delivery state that came after it.
// ══════════════════════════════════════════════════════════════════════
group('3c · the pickup rung, derived from authoritative facts', () {
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
Map<String, dynamic> row(String status) => {
'orderid': 'B1',
'pickupid': 'B1',
'sourcename': 'Vidhya Kitchen',
'orderstatus': status,
};
StopStatus rung(
String status, {
Set<String> collected = const {},
Set<String> arrived = const {},
Set<String> released = const {},
Set<String> accepted = const {'B1'},
}) => MilkRun.stageOf(
row(status),
acceptedIds: accepted,
collectedIds: collected,
arrivedIds: arrived,
outForDeliveryIds: released,
);
test('the backend\'s pickup words parse at all', () {
// Each of these was StopStatus.unknown.
expect(stopStatusFromRaw('pickup_scheduled'), StopStatus.accepted);
expect(stopStatusFromRaw('converted_to_consignment'), StopStatus.picked);
expect(stopStatusFromRaw('picked_up'), StopStatus.picked);
});
test('pickup_scheduled, nothing else → Accepted', () {
expect(rung('pickup_scheduled'), StopStatus.accepted);
});
test('pickup_scheduled + an arrival fact → Arrived', () {
expect(
rung('pickup_scheduled', arrived: {'B1'}),
StopStatus.arrived,
reason:
'the arrival is the rider\'s own record; nothing outranks it '
'here because nothing further along has happened',
);
});
test('converted_to_consignment → Picked', () {
expect(rung('converted_to_consignment'), StopStatus.picked);
});
test('picked_up → Picked', () {
expect(rung('picked_up'), StopStatus.picked);
});
test('active + pickup evidence → Picked on the pickup UI', () {
// The case the whole separation exists for. The delivery lifecycle has
// moved on; the pickup milestone did not un-happen.
expect(rung('active', collected: {'B1'}), StopStatus.picked);
});
test('out_for_delivery + pickup evidence → Picked on the pickup UI', () {
expect(rung('out_for_delivery', collected: {'B1'}), StopStatus.picked);
});
test('delivered keeps its own rung, not the pickup one', () {
// A delivered stop is finished. The pickup milestone is history, and the
// rung a rider is shown is the one that is current.
expect(rung('delivered', collected: {'B1'}), StopStatus.delivered);
});
test('active WITHOUT pickup evidence stays active', () {
// The line the brief drew: do not infer Picked merely because a booking
// is Active. Without a collection fact there is nothing to infer from.
//
// `accepted` is emptied here on purpose: the local accept store is
// checked below `active` in the ladder, so leaving it in would answer
// `accepted` and the test would pass for the wrong reason.
expect(rung('active', accepted: const {}), StopStatus.active);
});
test('the delivery resolver still reads active as active', () {
// Nothing about the backend's meaning is being renamed.
expect(stopStatusFromRaw('active'), StopStatus.active);
expect(stopStatusFromRaw('out_for_delivery'), StopStatus.outForDelivery);
});
test('an arrival record cannot walk a picked stop backwards', () {
expect(
rung('converted_to_consignment', arrived: {'B1'}, collected: {'B1'}),
StopStatus.picked,
);
expect(
rung('out_for_delivery', arrived: {'B1'}, released: {'B1'}),
StopStatus.outForDelivery,
);
expect(rung('delivered', arrived: {'B1'}), StopStatus.delivered);
});
});
// ══════════════════════════════════════════════════════════════════════
// 3d · START DELIVERY IS THE ONLY THING THAT MAKES A ROUND ACTIVE
//
// The one rider action that moves `Collected_By_Miler → Out_for_Delivery`.
// Not pickup-complete, not opening the Deliveries tab, not navigation, not
// availability — all of which have at some point been mistaken for it.
// ══════════════════════════════════════════════════════════════════════
group('3d · start-delivery, and what must not call it', () {
late List<http.Request> sent;
setUp(() {
SharedPreferences.setMockInitialValues({});
sent = <http.Request>[];
MilerApi.client = MockClient((req) async {
sent.add(req);
return http.Response(
jsonEncode({'success': true, 'data': {}}),
200,
headers: {'content-type': 'application/json'},
);
});
});
tearDown(() => MilerApi.client = http.Client());
test('it is keyed on the consignment, with no body', () async {
await MilerApi.startDelivery('C-4211');
final req = sent.single;
expect(req.method, 'POST');
// The **consignment**, not the booking. They are different sequences and
// posting one where the other belongs is a 404 the rider is shown as
// success.
expect(
req.url.path,
endsWith('/miler/consignments/C-4211/start-delivery'),
);
expect(
jsonDecode(req.body),
isEmpty,
reason:
'the contract takes no parameters; inventing one is how a '
'handler starts rejecting a call that used to work',
);
expect(req.headers['Idempotency-Key'], isNotNull);
});
test('pickup-complete does not start a round by itself', () async {
await MilerApi.pickupComplete(4211, lat: 11.0, lon: 76.9);
expect(
sent.where((r) => r.url.path.contains('start-delivery')),
isEmpty,
reason:
'Picked has to stay visible until the rider sets off; a '
'pickup that starts its own round erases the rung',
);
expect(sent.single.url.path, contains('pickup-complete'));
});
test('availability is its own call and touches no lifecycle', () async {
await MilerApi.setAvailability('On_Pickup');
final req = sent.single;
expect(req.url.path, contains('/miler/availability'));
expect(
req.url.path,
isNot(contains('bookings')),
reason: 'availability is a fact about the RIDER',
);
expect(req.url.path, isNot(contains('consignments')));
});
});
// ══════════════════════════════════════════════════════════════════════
// 4 · ARRIVED PRECEDENCE
//
// The local store exists only because `reached` did not persist. It is the
// weakest record in the app and its one rule is that it must never compete
// with the server: it may speak where the server has said nothing further
// along, and nowhere else.
//
// The rule used to be three exclusions — not complete, not cancelled, not
// rejected — and `isWorkComplete` is line-dependent: on a milk run it means
// *delivered or cancelled* only. So `picked`, `outForDelivery` and
// `deliveryArrived` all passed it, and a stale local record could walk a stop
// the hub knew was on the road back to a rung the rider left an hour ago.
// ══════════════════════════════════════════════════════════════════════
group('4 · the local ARRIVED record never outranks the server', () {
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
/// The shipped rule, reached through the same public surface `_fetchQueues`
/// uses — the status the server reported, parsed.
bool mayShow(String serverStatus) =>
HomepageArrivalPrecedence.localArrivalMayShow(
stopStatusFromRaw(serverStatus),
);
test('it may speak where the server has nothing further along', () {
for (final said in const [
'new',
'assigned',
'accepted',
'arrived',
'this is not a status the app knows',
]) {
expect(
mayShow(said),
isTrue,
reason: '"$said" is not a rung past arrival',
);
}
});
test('it is silent once the server has moved the stop on', () {
// Every one of these passed the old three-exclusion test on a milk run.
for (final said in const [
'picked',
'outfordelivery',
'deliveryarrived',
'delivered',
'cancelled',
'rejected',
]) {
expect(
mayShow(said),
isFalse,
reason:
'"$said" is the server\'s own answer — a local record that '
'overwrote it would walk the stop backwards',
);
}
});
test('the rung the map path writes on arrival is ARRIVED, not ACTIVE', () {
// ── Where "Picked" became "Active" ──
//
// `_startPickupNavigation` wrote `orderstatus = 'active'` onto the row.
// Two separate facts were going into one field: *which stop is live*,
// which `_activePickupOrderId` already carries and is what the LIVE mark
// reads, and *how far up the pickup ladder this stop is*.
//
// `active` was never a server state either. The provider maps it to
// `setAvailability('On_Pickup')` — a fact about the **rider** — because
// there is no rider endpoint that sets a booking Active;
// `Pickup_Scheduled` is system-set. So the word was app-generated and it
// outranked the real rung on every screen reading `orderstatus`.
//
// Pinned on the resolver, not on any widget's text.
expect(
stopStatusFromRaw('active'),
StopStatus.active,
reason: 'active parses as its own thing and is not a pickup rung',
);
expect(stopStatusFromRaw('active'), isNot(StopStatus.picked));
expect(stopStatusFromRaw('active').isPicked, isFalse);
expect(
stopStatusFromRaw('active').isFinishedPickup,
isFalse,
reason: 'an active stop is not a collected one',
);
});
test('the server\'s own arrival word resolves to the arrived rung', () {
for (final said in const [
'Arrived_At_Pickup',
'arrived_at_pickup',
'ARRIVEDATPICKUP',
'arrived',
]) {
expect(
stopStatusFromRaw(said),
StopStatus.arrived,
reason: '"$said" must land on the rung the rider was shown',
);
}
});
test('picked and active are distinct rungs, and stay distinct', () {
// The explicit regression: `Picked != Active`.
expect(stopStatusFromRaw('picked'), StopStatus.picked);
expect(stopStatusFromRaw('picked'), isNot(stopStatusFromRaw('active')));
expect(stopStatusFromRaw('picked').isPicked, isTrue);
});
test('and it is silent on a parcel route too', () {
// `isWorkComplete` differs by line, which is exactly what made the old
// blacklist leak. The rule must not depend on which line is running.
ServiceProfile.setActive(ServiceProfile.parcel);
expect(mayShow('picked'), isFalse);
expect(mayShow('accepted'), isTrue);
});
});
}

View File

@@ -0,0 +1,109 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE RIDER IS NEVER TOLD ABOUT A HUB
///
/// "Hub" is an internal word. It names a building in the logistics network and
/// a row in the consignment state machine, and neither is something a rider has
/// any use for on a screen.
///
/// ── The failure that made this a rule ──
///
/// A meal-run rider slid **Start ride** on a stop 8 km from the kitchen and was
/// answered: *"This parcel is with the hub — it will be delivered from there,
/// not by you."* Every part of that is wrong for him. There is no hub on a
/// hyperlocal round; the food was in his box; and the sentence told him to give
/// up on a stop that was his. He could do nothing with it, because it described
/// a system he does not work in.
///
/// The state behind it (`Inwarded_at_Hub` and friends) cannot occur on that
/// line at all — see `_handoffHoldIsPossible` — so the message was a bad read
/// wearing confident words. That half is fixed in code. This is the other half:
/// the vocabulary itself does not belong in front of a rider, on any line.
///
/// ── What is still allowed ──
///
/// `debugPrint` and `ApiConfig.logGap` are engineering output — they are read
/// in a console by whoever is chasing a backend gap, and "hub" is the right
/// word there. Wire values (`inwarded_at_hub`) are the backend's own spelling
/// and must match it exactly. Identifiers are internal.
///
/// Everything else — anything a rider can read — says *your office*, or names
/// the destination *base*.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
test('no rider-facing string says "hub"', () {
// A comment, or the backend's own wire spelling.
bool exemptLine(String line) {
final l = line.trimLeft();
if (l.startsWith('//') || l.startsWith('///') || l.startsWith('*')) {
return true;
}
return l.contains('inwarded_at_hub') || l.contains('Inwarded_at_Hub');
}
/// True while the scan is inside a `debugPrint(...)` / `logGap(...)` call.
///
/// Tracked across lines rather than matched on one, because these calls
/// wrap: the string that names the hub sits three lines below the
/// `debugPrint(` that makes it engineering output rather than copy. A
/// same-line check called those a violation and would have pushed the next
/// person to reword a console log.
var diagDepth = 0;
bool inDiagnostic(String line) {
final opensHere =
diagDepth == 0 &&
(line.contains('debugPrint(') || line.contains('logGap('));
if (diagDepth > 0 || opensHere) {
// Count only from the call itself on the line that opens it.
final from = opensHere
? line.indexOf(RegExp(r'(debugPrint|logGap)\('))
: 0;
for (final c in line.substring(from).split('')) {
if (c == '(') diagDepth++;
if (c == ')') diagDepth--;
}
if (diagDepth < 0) diagDepth = 0;
return true;
}
return false;
}
// "hub" as a WORD inside a quoted literal — `hubid`, `_hubRow` and
// `endsAtHub` are identifiers and none of them reach a screen.
final quoted = RegExp(r"""(['"])((?:\\.|(?!\1).)*)\1""");
final word = RegExp(r'\bhubs?\b', caseSensitive: false);
final offenders = <String>[];
for (final e in Directory('lib').listSync(recursive: true)) {
if (e is! File || !e.path.endsWith('.dart')) continue;
// The Xpress reference implementation is not this app's UI, and no live
// tenant can route into it. See `TenantController.deliveryTenantIds`.
if (e.path.contains('/xpress/')) continue;
final lines = e.readAsLinesSync();
diagDepth = 0;
for (var i = 0; i < lines.length; i++) {
if (inDiagnostic(lines[i])) continue;
if (exemptLine(lines[i])) continue;
for (final m in quoted.allMatches(lines[i])) {
final literal = m.group(2) ?? '';
if (word.hasMatch(literal)) {
offenders.add('${e.path}:${i + 1} "$literal"');
}
}
}
}
expect(
offenders,
isEmpty,
reason:
'These strings can reach a rider and name an internal concept.\n'
'Use "your office" for the people, "base" for the place:\n'
' ${offenders.join('\n ')}',
);
});
}

View File

@@ -0,0 +1,430 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart';
import 'package:miler/views/Dashboard/home/stop_detail_sheet.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/home/trip_card.dart';
import 'package:miler/data/bag_manifest.dart';
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// WHICH LEG A SHEET IS ABOUT, AND HOW TALL IT IS
///
/// ── Two surfaces, two subjects ──
///
/// Home's kitchen card and the order rows inside it answer different
/// questions, and both used to answer the first one.
///
/// Every order under a counter shares that counter's address, so a sheet
/// opened from an order row that pinned the counter drew the identical map
/// once per order and said nothing about the order the rider actually tapped.
/// What distinguishes one order from the next is the **door it is going to**.
///
/// The place is not left without a map — it has a better one, on the preview
/// behind the card's third utility, which draws the whole leg from where the
/// rider is standing to the counter with the manifest waiting on it.
///
/// ── Why the payload's own `type` could not decide it ──
///
/// `ApiConfig.pickupFromBooking` stamps `type: pickup` on every row it builds,
/// because a v1 booking *is* a first-mile pickup and the backend has no
/// per-stop type to send. So the sheet's `_isDropLeg` — which reads that type
/// through `MilkRun.workingKind` — answered "pickup" for every uncollected
/// meal order, and the customer's sheet showed the kitchen. The caller knows
/// what it opened the sheet for; the payload does not. Hence
/// [StopDetailSheet.ofOrder].
///
/// ── And the sheet reserved space it did not use ──
///
/// It was a `DraggableScrollableSheet` at `initialChildSize: 0.78`, which
/// claims 78% of the screen whether or not it has 78% of a screen to say. A
/// meal order ended at its CONTACT tile with a third of the sheet left over as
/// blank paper. It measures its content now, capped at 86%.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
tearDown(() {
Get.reset();
ServiceProfile.setActive(ServiceProfile.parcel);
});
const kitchenStreet = '9 Kitchen Street, RS Puram, Coimbatore 641002';
const customerStreet = '12, SNS Colony, Peelamedu, Coimbatore 641004';
/// An order exactly as the adapter hands it over — including the `pickup`
/// type it stamps on every row, which is the shape that broke.
Map<String, dynamic> order({bool withDrop = true}) => <String, dynamic>{
'orderid': 'D1',
'pickupid': 1001,
'step': 1,
'kitchenname': 'Vidhya Kitchen',
'sourceid': 'K1',
'pickupcustomer': 'Joe Mathew',
'pickupcontactno': '9787698259',
'pickupaddress': kitchenStreet,
'pickuplat': 11.0168,
'pickuplon': 76.9558,
if (withDrop) 'dropaddress': customerStreet,
if (withDrop) 'droplat': 11.0208,
if (withDrop) 'droplon': 76.9598,
'orderstatus': 'accepted',
'type': 'pickup',
'baglabel': 'Bag 1',
'Quantity': 1,
};
Future<void> openSheet(
WidgetTester tester,
Future<void> Function(BuildContext) open, {
double height = 844,
}) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
tester.view.physicalSize = Size(390 * 3, height * 3);
tester.view.devicePixelRatio = 3;
addTearDown(tester.view.reset);
// Tear down whatever was mounted before, routes included. Without this a
// test that opens the sheet twice measures the *first* one both times —
// the old route is still on the navigator, and `.first` finds it.
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
late BuildContext ctx;
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: Builder(
builder: (c) {
ctx = c;
return const SizedBox.expand();
},
),
),
),
),
);
unawaited(open(ctx));
await tester.pumpAndSettle(const Duration(seconds: 1));
}
group('an order points at its own door', () {
testWidgets('opened from an order row, the sheet shows the delivery', (
tester,
) async {
await openSheet(
tester,
(c) => StopDetailSheet.show(
c,
stop: order(),
stopNumber: 1,
ofOrder: true,
riderLat: 11.0168,
riderLng: 76.9558,
),
);
expect(find.text('DELIVER TO'), findsOneWidget);
expect(find.text(customerStreet), findsOneWidget);
expect(
find.text(kitchenStreet),
findsNothing,
reason: 'the counter is the card\'s subject, not the order\'s',
);
});
testWidgets('opened for a place, the sheet still shows the place', (
tester,
) async {
// A flat group's header IS a location — one stop, one counter — so
// nothing about that case changes.
await openSheet(
tester,
(c) => StopDetailSheet.show(c, stop: order(), stopNumber: 1),
);
expect(find.text('PICKUP LOCATION'), findsOneWidget);
expect(find.text(kitchenStreet), findsOneWidget);
expect(find.text(customerStreet), findsNothing);
});
testWidgets('a row with no drop falls back without lying about it', (
tester,
) async {
// The half-populated payload. What must never happen is `DELIVER TO`
// printed over a kitchen's street: the eyebrow, the street and the pin
// are resolved together, so falling back to the pickup means it stops
// calling itself a drop.
await openSheet(
tester,
(c) => StopDetailSheet.show(
c,
stop: order(withDrop: false),
stopNumber: 1,
ofOrder: true,
),
);
expect(find.text('DELIVER TO'), findsNothing);
expect(find.text('PICKUP LOCATION'), findsOneWidget);
expect(find.text(kitchenStreet), findsOneWidget);
});
});
group('the place keeps its own leg, on the preview', () {
testWidgets('the preview is where the counter is drawn', (tester) async {
// The pickup did not disappear when the order sheet stopped showing it —
// it moved to the surface that is actually about the place, behind the
// card's third utility, where it comes with the manifest.
await openSheet(
tester,
(c) => PickupPreviewSheet.show(
c,
placeName: 'Vidhya Kitchen',
lines: [
BagLine(
stop: order(),
orderId: 'D1',
customer: 'Joe Mathew',
bag: 'Bag 1',
),
BagLine(
stop: order(),
orderId: 'D2',
customer: 'Priya Nair',
bag: 'Bag 2',
),
],
meters: 3100,
ride: const Duration(minutes: 9),
riderLat: 11.0168,
riderLng: 76.9558,
onNavigate: () {},
onCall: () {},
),
);
expect(find.text('Vidhya Kitchen'), findsOneWidget);
expect(find.text('Joe Mathew'), findsOneWidget);
expect(find.text('Priya Nair'), findsOneWidget);
});
});
group('the sheet is as tall as what is on it', () {
Finder scaffold() => find.byType(MilerSheetScaffold).first;
Finder viewport() => find
.descendant(
of: scaffold(),
matching: find.byType(SingleChildScrollView),
)
.first;
Finder content() =>
find.descendant(of: viewport(), matching: find.byType(Column)).first;
testWidgets('a short order reserves no blank paper below it', (
tester,
) async {
await openSheet(
tester,
(c) => StopDetailSheet.show(
c,
stop: order(),
stopNumber: 1,
ofOrder: true,
riderLat: 11.0168,
riderLng: 76.9558,
),
);
// ── The measurement that actually names the complaint ──
//
// "Shorter than before" is the wrong assertion: a content-sized sheet is
// sometimes *taller* than the fixed 78% it replaced, because it stopped
// hiding the last tile behind a scroll. What was wrong was the gap — the
// list's viewport was 78% of the screen while the rows inside it ended a
// third of the way up it, and the difference was blank paper.
//
// So: the scroll viewport is exactly as tall as what is inside it. That
// is true of a content-sized sheet at any height, and false of a fixed
// one at every height short of full.
expect(
tester.getSize(viewport()).height,
tester.getSize(content()).height,
reason: 'a viewport taller than its content IS the blank space',
);
});
testWidgets('the sheet grows and shrinks with what is on it', (
tester,
) async {
await openSheet(
tester,
(c) => StopDetailSheet.show(c, stop: order(), stopNumber: 1),
);
final plain = tester.getSize(scaffold()).height;
await openSheet(
tester,
(c) => StopDetailSheet.show(
c,
stop: order()..['notes'] = 'Gate code 4411, ring the watchman.',
stopNumber: 1,
),
);
final withNote = tester.getSize(scaffold()).height;
expect(
withNote,
greaterThan(plain),
reason: 'a fixed 0.78 would have reported the same number twice',
);
});
testWidgets('a long order is capped, and scrolls rather than overflows', (
tester,
) async {
final long = order()
..['pickupcustomer'] = 'Venkataraman Subramanian Balasubramanian'
..['notes'] =
'Gate code 4411, leave with security if unreachable. Second '
'building on the left after the temple, ask for the watchman at '
'the side entrance, the main gate is locked after nine, and the '
'lift stops running at eleven so use the back stairs.'
..['dropaddress'] =
'Flat 14B, Second Floor, Sri Venkateswara Residency, 12 SNS '
'Colony Extension, Behind the Water Tank, Peelamedu, Coimbatore, '
'Tamil Nadu 641004';
await openSheet(
tester,
(c) => StopDetailSheet.show(
c,
stop: long,
stopNumber: 7,
ofOrder: true,
bag: 'Bag 1',
),
);
expect(tester.takeException(), isNull);
// The ceiling keeps the scrim — and the route behind it — visible at the
// top of every phone.
expect(tester.getSize(scaffold()).height, lessThanOrEqualTo(844 * 0.87));
// And the overflow goes to the scroll view rather than off the sheet.
expect(
tester.getSize(content()).height,
greaterThan(tester.getSize(viewport()).height),
);
expect(find.text('DELIVER TO'), findsOneWidget);
});
});
group('the timeline routes the two taps to different handlers', () {
Future<void> pumpCard(
WidgetTester tester, {
required List<Map<String, dynamic>> stops,
void Function(Map<String, dynamic>)? onStopTap,
void Function(Map<String, dynamic>)? onOrderTap,
}) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
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: (_, _) => MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: TripCard(
trip: Trip.fromStops(id: 'T1', stops: stops),
onStopTap: onStopTap,
onOrderTap: onOrderTap,
onToggleSelect: (_) {},
),
),
),
),
),
);
await tester.pumpAndSettle();
}
/// The `Bag n ›` block — the one control on an order row that opens its
/// sheet, found by the promise it makes to a screen reader.
Finder openBlock() => find.byWidgetPredicate(
(w) => w is Semantics && w.properties.hint == 'Opens order details',
);
Map<String, dynamic> grouped(String id, String who) => <String, dynamic>{
'orderid': id,
'pickupid': int.parse(id),
'step': int.parse(id) - 1000,
'kitchenname': 'Vidhya Kitchen',
'sourceid': 'K1',
'pickupcustomer': who,
'pickupaddress': kitchenStreet,
'dropaddress': customerStreet,
'orderstatus': 'accepted',
'type': 'pickup',
'pickuplat': 11.01,
'pickuplon': 76.95,
};
testWidgets('tapping an order row calls onOrderTap, not onStopTap', (
tester,
) async {
final orders = <Map<String, dynamic>>[];
final places = <Map<String, dynamic>>[];
await pumpCard(
tester,
stops: [grouped('1001', 'Joe Mathew'), grouped('1002', 'Priya Nair')],
onStopTap: places.add,
onOrderTap: orders.add,
);
// The group is open by default — it is the one the rider is working.
expect(find.text('Joe Mathew'), findsOneWidget);
// The name is the *selection* target; the way into an order's sheet is
// the `Bag 1 ›` block at the end of its row. One gesture, one meaning —
// see the note on `_OrderOpenBlock`.
await tester.tap(openBlock().first);
await tester.pumpAndSettle();
expect(orders.length, 1, reason: 'an order row is about one order');
expect(orders.single['orderid'], '1001');
expect(places, isEmpty, reason: 'the place has its own handler');
});
testWidgets('onOrderTap falls back to onStopTap when unset', (
tester,
) async {
// A caller that does not care keeps the old single-callback behaviour,
// so nothing outside Home has to know this split exists.
final places = <Map<String, dynamic>>[];
await pumpCard(
tester,
stops: [grouped('1001', 'Joe Mathew'), grouped('1002', 'Priya Nair')],
onStopTap: places.add,
);
await tester.tap(openBlock().first);
await tester.pumpAndSettle();
expect(places.length, 1);
expect(places.single['orderid'], '1001');
});
});
}

View File

@@ -0,0 +1,309 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/api_status.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/data/consignment_state.dart';
import 'package:miler/data/lifecycle.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// PICKED IS NOT ACTIVE, AND ONLY THE SLIDER MOVES IT
///
/// The production lifecycle the hub, the console and this app must all agree
/// on:
///
/// ```
/// Pending → Accepted → Arrived → Picked → Active → Delivered
/// ▲ ▲
/// pickup-complete start-delivery
/// Collected_By_Miler Out_for_Delivery
/// ```
///
/// Two separate defects collapsed **Picked** into **Active**, and fixing either
/// alone leaves the symptom:
///
/// 1. **Backend.** With `MILER_COLLECTED_STATE_ENABLED` off, `pickup-complete`
/// writes `Out_for_Delivery` itself. Not reachable from this app and not
/// testable here — it is the deployment's behaviour. What IS pinned below
/// is that the app never claims Picked over such a response.
///
/// 2. **App.** `MyPickups.startPickup` released the consignment when the rider
/// merely *tapped a card* — one tap after Picked, before any slider. That
/// is invisible while the flag is off (the pivot has already released, so
/// the release costs no request) and becomes the whole bug the moment the
/// flag is flipped. Removed; pinned here.
///
/// These tests are the reason the flag flip is safe. Read them before changing
/// any call site that reaches `MilerApi.startDelivery`.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
// ══════════════════════════════════════════════════════════════════════
// 1 · PICKED DOES NOT CALL START-DELIVERY
//
// Enforced at the source, because this is a statement about *call sites*
// and no behavioural test can prove the absence of one. Every route to
// `MilerApi.startDelivery` is enumerated; a new one fails this test and has
// to be justified here rather than discovered in production.
// ══════════════════════════════════════════════════════════════════════
group('only the rider setting off releases a consignment', () {
/// Every `lib/` file that mentions a symbol, with its line numbers.
Map<String, List<int>> sitesOf(String symbol) {
final out = <String, List<int>>{};
for (final e in Directory('lib').listSync(recursive: true)) {
if (e is! File || !e.path.endsWith('.dart')) continue;
final lines = e.readAsLinesSync();
for (var i = 0; i < lines.length; i++) {
final l = lines[i];
// Prose in a doc comment is not a call.
if (l.trimLeft().startsWith('//') || l.trimLeft().startsWith('///')) {
continue;
}
if (l.contains(symbol)) (out[e.path] ??= []).add(i + 1);
}
}
return out;
}
test('`MilerApi.startDelivery` has exactly one caller', () {
final sites = sitesOf('MilerApi.startDelivery(');
expect(
sites.keys.toSet(),
{'lib/views/Dashboard/pickups/delivery_actions.dart'},
reason:
'the release is `releaseForDelivery`\'s alone. A second caller is '
'a second lifecycle, and the console sees both.',
);
});
test('`releaseForDelivery` is reached only from setting off or the door', () {
final sites = sitesOf('releaseForDelivery(')
..remove('lib/views/Dashboard/pickups/delivery_actions.dart');
expect(
sites.keys.toSet(),
{
// The Start-ride slider on the stop's own map.
'lib/views/Dashboard/pickups/map.dart',
// The Start-round bar, and nothing else on that screen. This file
// held the defect: `startPickup` called `startRound` on card open.
'lib/views/Dashboard/pickups/pickups.dart',
},
reason: 'a new caller must be a deliberate "the rider has set off"',
);
});
test('tapping a card does not release — the defect, pinned', () {
// `startPickup` opens a stop's map. It must contain no release: the
// rider tapping a card to read an address has not set off, and
// `navigatesToCustomer` is true from the instant Picked succeeds.
final src = File(
'lib/views/Dashboard/pickups/pickups.dart',
).readAsStringSync();
final start = src.indexOf('Future<void> startPickup(');
expect(start, greaterThan(0), reason: 'startPickup was renamed');
final end = src.indexOf('Future<void> resumePickup(', start);
expect(end, greaterThan(start));
final body = src
.substring(start, end)
.split('\n')
.where((l) => !l.trimLeft().startsWith('//'))
.join('\n');
expect(
body.contains('startRound('),
isFalse,
reason:
'opening a stop released it, so the console read Active one tap '
'after Picked and before any slider was touched',
);
expect(body.contains('releaseForDelivery('), isFalse);
});
});
// ══════════════════════════════════════════════════════════════════════
// 2 · COLLECTED_BY_MILER IS PICKED, AND STAYS PICKED
// ══════════════════════════════════════════════════════════════════════
group('Collected_By_Miler reads as Picked', () {
test('the pivot response is read as collected, not as released', () {
final t = MilerLifecycle.pickupComplete(
ApiResult(
ok: true,
status: 200,
data: const {
'bookingid': 78,
'consignmentid': 34,
'status': 'Converted_To_Consignment',
'consignmentstatus': 'Collected_By_Miler',
'next_action': 'start_delivery',
},
),
);
expect(t.isConfirmed, isTrue);
expect(t.bookingStatus, BookingStatus.convertedToConsignment);
expect(t.consignmentState, ConsignmentState.collectedByMiler);
expect(t.consignmentId, '34');
expect(t.awaitsStartDelivery, isTrue);
expect(
t.isCompatibilityMode,
isFalse,
reason: 'the flag is ON in this response — the release is still to come',
);
});
test('a collected stop shows the Picked rung', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
final stop = <String, dynamic>{
'orderid': 'A1',
'orderstatus': ApiConfig.legacyStatusFromConsignment(
'Collected_By_Miler',
),
'consignmentstatus': 'Collected_By_Miler',
};
expect(stop['orderstatus'], 'picked', reason: 'adapter contract');
expect(
MilkRun.stageOf(stop, acceptedIds: const {}, collectedIds: const {'A1'}),
StopStatus.picked,
);
});
test('and it still needs releasing — the bar has work to offer', () {
expect(
consignmentStateFromRaw('Collected_By_Miler').needsRelease,
isTrue,
);
expect(
consignmentStateFromRaw('Collected_By_Miler').isDeliverable,
isFalse,
reason: '`deliver` accepts Out_for_Delivery only',
);
});
});
// ══════════════════════════════════════════════════════════════════════
// 3 · START RIDE IS WHAT MAKES IT ACTIVE
// ══════════════════════════════════════════════════════════════════════
group('Start ride moves collected → out for delivery', () {
test('the released state is the Active rung, and only via the local set', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
final stop = <String, dynamic>{
'orderid': 'A1',
'orderstatus': ApiConfig.legacyStatusFromConsignment('Out_for_Delivery'),
'consignmentstatus': 'Out_for_Delivery',
};
// Before the slider: the rider's own record does not carry it, so the
// rung is Picked even though the consignment is released.
expect(
MilkRun.stageOf(stop, acceptedIds: const {}, collectedIds: const {'A1'}),
StopStatus.picked,
);
// After the slider: `startRound` writes the id, and only then.
expect(
MilkRun.stageOf(
stop,
acceptedIds: const {},
collectedIds: const {'A1'},
outForDeliveryIds: const {'A1'},
),
StopStatus.outForDelivery,
);
});
test('a released consignment is deliverable and needs no second release', () {
final s = consignmentStateFromRaw('Out_for_Delivery');
expect(s.isDeliverable, isTrue);
expect(s.needsRelease, isFalse);
expect(ConsignmentGate.gateFor(s), DeliverGate.deliverable);
});
});
// ══════════════════════════════════════════════════════════════════════
// 4 · A STALE OR EARLY ROW CANNOT COLLAPSE PICKED INTO ACTIVE
//
// `GET /miler/bookings` is a poll behind the rider by design. With the flag
// OFF the pivot releases the consignment itself, so the very next poll
// reports `Out_for_Delivery` for a bag still on the counter. The rider's
// card must not jump to Active on that — his own set is what says he set
// off. This is the app half of the compatibility-mode disagreement, and it
// is display only: nothing here is sent to the server.
// ══════════════════════════════════════════════════════════════════════
group('polling cannot start the round for the rider', () {
test('compatibility mode is reported, never silently adopted', () {
final t = MilerLifecycle.pickupComplete(
ApiResult(
ok: true,
status: 200,
data: const {
'bookingid': 78,
'consignmentid': 34,
'consignmentstatus': 'Out_for_Delivery',
},
),
);
expect(t.isConfirmed, isTrue);
expect(t.consignmentState, ConsignmentState.outForDelivery);
expect(
t.isCompatibilityMode,
isTrue,
reason:
'the flag is OFF on this deployment — the pivot released it, and '
'the console showing Active is the SERVER\'s state, not a client '
'bug. Reported so the trace names the backend.',
);
expect(t.awaitsStartDelivery, isFalse);
});
test('an early Out_for_Delivery row still draws Picked', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
// Exactly what the next poll returns in compatibility mode.
final stop = <String, dynamic>{
'orderid': 'A1',
'orderstatus': 'outfordelivery',
'consignmentstatus': 'Out_for_Delivery',
};
expect(
MilkRun.stageOf(stop, acceptedIds: const {}, collectedIds: const {'A1'}),
StopStatus.picked,
reason:
'the rider has not slid Start ride; the row is the consignment\'s '
'state, not his',
);
});
test('but a genuinely finished stop is never dragged backwards', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
for (final raw in const ['Delivered', 'delivered']) {
// Built the way `ApiConfig.pickupFromBooking` builds a real row: the
// consignment's word is translated into `orderstatus`, which is what
// `stopStatusOf` reads. Setting `consignmentstatus` alone would be a
// row this app never sees, and would prove nothing about the ladder.
final stop = <String, dynamic>{
'orderid': 'A1',
'orderstatus': ApiConfig.legacyStatusFromConsignment(raw),
'consignmentstatus': raw,
};
expect(stop['orderstatus'], 'delivered', reason: 'adapter contract');
expect(
MilkRun.stageOf(
stop,
acceptedIds: const {},
collectedIds: const {'A1'},
),
StopStatus.delivered,
reason: 'precedence must only ever hold a stop back, never a close',
);
}
});
});
}

View File

@@ -196,6 +196,38 @@ void main() {
);
expect(deliveries.map(MilkRun.idOf), ['A1']);
});
test('a delivered stop leaves the count, not just the list', () {
// ── The two screens disagreed because they asked different questions ──
//
// Both call `deliveryQueue`, which is supposed to be what stops that
// happening. It cannot, when they pass different arguments: Deliveries
// passed `closedIds`, Home's pill did not. The rider finished a drop,
// watched the tab go 22 → 21, and came back to Home still holding 22.
//
// Worse on Home than a plain lag, because Home *stamps* a closed stop
// `picked` so its row cannot flicker back to LIVE while the queue
// catches up — and `picked` is squarely inside the delivery domain. The
// pill was counting the stamp that existed to hide the stop.
final day = [
order('B1', status: 'picked'),
order('B2', status: 'picked'),
];
expect(WorkBoundary.deliveryQueue(day).length, 2);
expect(
WorkBoundary.deliveryQueue(day, closedIds: const {'B1'}).length,
1,
reason: 'the finished drop is off both the list and the count',
);
// And it does not reappear on Home instead — closed is closed, not
// handed back across the boundary.
expect(
WorkBoundary.pickupQueue(day, closedIds: const {'B1'}).length,
0,
);
});
});
group('logistics — the boundary is acceptance, and that is unchanged', () {

View File

@@ -0,0 +1,117 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/pickup_locations.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE PICKUP IS NAMED BY THE TENANT'S LOCATION LIST
///
/// The route card's heading — the biggest type on Home — read `Sudharsan`,
/// a contact person, above a distance and an ETA to a place that name does not
/// identify. It came from the booking row's `sourcename`, which the backend
/// fills from `providercompany` / `providerlocation`.
///
/// The app cannot tell a person from a shop by looking at the string, so the
/// name is **joined** instead: the booking carries the location id, and
/// `GET /admin/tenants/:id/locations` carries the names.
///
/// The half that matters most is the fallback. That route is under `/admin`,
/// and whether a rider's token is admitted to it is the backend's call — so
/// every one of these proves the app is no worse off than before when the
/// table is empty.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
setUp(PickupLocations.reset);
tearDown(PickupLocations.reset);
Map<String, dynamic> stop({
Object? locationId,
String sourceName = '',
}) => {
'orderid': 'A1',
if (locationId != null) 'pickuplocationid': locationId,
'sourcename': sourceName,
};
group('with the tenant list loaded', () {
test('the location name replaces the person on the row', () {
PickupLocations.seed({'7': 'Sri Balaji Stores'});
expect(
MilkRun.sourceNameOf(stop(locationId: 7, sourceName: 'Sudharsan')),
'Sri Balaji Stores',
);
});
test('it also fills a row that names nothing at all', () {
PickupLocations.seed({'7': 'Gandhipuram Counter'});
expect(
MilkRun.sourceNameOf(stop(locationId: 7)),
'Gandhipuram Counter',
);
});
test('`sourceid` and `kitchenid` join on the same table', () {
PickupLocations.seed({'9': 'Peelamedu Kitchen'});
expect(
MilkRun.sourceNameOf({'orderid': 'A1', 'sourceid': 9}),
'Peelamedu Kitchen',
);
expect(
MilkRun.sourceNameOf({'orderid': 'A1', 'kitchenid': '9'}),
'Peelamedu Kitchen',
);
});
test('an id the tenant list does not hold falls back to the row', () {
PickupLocations.seed({'7': 'Sri Balaji Stores'});
expect(
MilkRun.sourceNameOf(stop(locationId: 99, sourceName: 'Sudharsan')),
'Sudharsan',
reason: 'a partial table must not blank out the names it does have',
);
});
});
group('with no tenant list — the admin route refused, or has not answered', () {
test('the row is read exactly as it was before', () {
expect(
MilkRun.sourceNameOf(stop(locationId: 7, sourceName: 'Sudharsan')),
'Sudharsan',
);
expect(MilkRun.sourceNameOf(stop(locationId: 7)), '');
});
test('and grouping still works off the id', () {
// The group key was never the name — it is the counter's id — so a
// missing table cannot merge two kitchens into one card.
final a = stop(locationId: 7);
final b = stop(locationId: 8);
expect(MilkRun.sameSource(a, a), isTrue);
expect(MilkRun.sameSource(a, b), isFalse);
});
});
group('the lookup itself', () {
test('an absent, empty or zero id is never a name', () {
PickupLocations.seed({'7': 'Sri Balaji Stores', '0': 'Nowhere'});
expect(PickupLocations.nameFor(null), '');
expect(PickupLocations.nameFor(''), '');
expect(PickupLocations.nameFor(' '), '');
expect(
PickupLocations.nameFor(0),
'',
reason: '0 is how this backend spells "unset", not a location',
);
expect(PickupLocations.nameFor(7), 'Sri Balaji Stores');
expect(PickupLocations.nameFor('7'), 'Sri Balaji Stores');
});
test('reset empties it, so a sign-out cannot leak names across riders', () {
PickupLocations.seed({'7': 'Sri Balaji Stores'});
expect(PickupLocations.isLoaded, isTrue);
PickupLocations.reset();
expect(PickupLocations.isLoaded, isFalse);
expect(PickupLocations.nameFor(7), '');
});
});
}

View File

@@ -66,19 +66,26 @@ void main() {
// still holds is the one that matters: whatever the bar's surface is, the
// type on it has to clear contrast against it, and every bar in the app has
// to agree on the answer.
testWidgets('is solid brand with a white title', (tester) async {
testWidgets('is the brand ground with a white title', (tester) async {
await pump(
tester,
Scaffold(appBar: milerPageBar('Saved Addresses', onBack: () {})),
);
final bar = tester.widget<AppBar>(find.byType(AppBar));
expect(bar.backgroundColor, ColorConstants.primary);
expect(
bar.flexibleSpace,
isNull,
reason: 'a solid ground needs no surface layered over it',
);
// ── The fill is transparent, and the surface paints ──
//
// It was the brand as a safety net behind [kBrandGradient]. It cannot be
// one now: the bar's bottom corners are rounded so the page shows through
// them (see [milerBrandSurface]), and an opaque rectangle behind a
// rounded surface fills in exactly the two corners the curve exists to
// open. What must hold is that the bar still *renders* the brand, which
// is the gradient assertion below.
expect(bar.backgroundColor, Colors.transparent);
// [kBrandGradient], as a `flexibleSpace` — a colour cannot be a gradient.
// The fill stays the brand red underneath, so a bar that fails to paint
// its surface comes out the right colour rather than transparent.
expect(bar.flexibleSpace, isNotNull);
expect(
bar.systemOverlayStyle?.statusBarIconBrightness,
Brightness.light,

View File

@@ -0,0 +1,65 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE RETIRED PLATFORM IS NOT NAMED ANYWHERE
///
/// The delivery screens under `lib/xpress` are a verbatim port of a rider app
/// that belonged to a platform which has since been shut down. The port
/// brought its hostnames, its image host and its name in comments with it, and
/// those references outlived the thing they referred to.
///
/// A dead product's name in a live repo is not a cosmetic problem. It sends the
/// next reader looking for a backend that no longer answers, and it is exactly
/// the sort of string that gets pasted into a config file by somebody trying to
/// make a screen work.
///
/// ── Why the word is assembled rather than written ──
///
/// This file has to know the banned string in order to look for it, and a
/// literal here would be the very thing it forbids — the ban would fail its own
/// test. Joining two halves keeps the guard real and keeps the repo clean.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
// The retired platform's name, never spelled in one piece. See above.
final banned = <String>[
['nea', 'rle'].join(),
];
test('no source, test or document names the retired platform', () {
final offenders = <String>[];
for (final dir in const ['lib', 'test']) {
for (final entity in Directory(dir).listSync(recursive: true)) {
if (entity is! File) continue;
if (!entity.path.endsWith('.dart')) continue;
// This file is the one place the string legitimately exists, in two
// halves — skip it rather than assemble a special case for it.
if (entity.path.endsWith('retired_platform_test.dart')) continue;
final text = entity.readAsStringSync().toLowerCase();
for (final word in banned) {
if (text.contains(word)) offenders.add(entity.path);
}
}
}
for (final doc in Directory('.').listSync()) {
if (doc is! File || !doc.path.endsWith('.md')) continue;
final text = doc.readAsStringSync().toLowerCase();
for (final word in banned) {
if (text.contains(word)) offenders.add(doc.path);
}
}
expect(
offenders,
isEmpty,
reason:
'the retired platform is named in: ${offenders.join(', ')} — it was '
'shut down, so a reference to it points at nothing and reads as a '
'live dependency to the next person through this file',
);
});
}

View File

@@ -0,0 +1,357 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/rider_stage.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE RIDER-FACING LIFECYCLE, END TO END
///
/// ```
/// Pending → Accepted → Arrived → Picked → Active → Delivered
/// ```
///
/// Six stages against a backend lifecycle that has five and no arrival in it.
/// The gap is not an oversight on their side: arrival is an **event**, written
/// by `/reached` as a timestamp beside a status that does not move, and asking
/// them to add a rung would be asking them to model an event as a state.
///
/// So the app owes the richer workflow, and these hold it to it. Two resolvers
/// are exercised, deliberately, because they answer different questions about
/// the same order:
///
/// • [riderStageOf] — where is this order in the rider's day?
/// • [MilkRun.stageOf] — how far did the *pickup* get?
///
/// A stop whose delivery is under way is `Active` on the first and `Picked` on
/// the second, and both are correct. That separation is the point of the whole
/// exercise, so it is asserted rather than assumed.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
const id = 'B1';
Map<String, dynamic> row(String status) => {
'orderid': id,
'pickupid': id,
'sourcename': 'Vidhya Kitchen',
'orderstatus': status,
};
RiderStage stage(
String status, {
Set<String> arrived = const {},
Set<String> collected = const {},
Set<String> released = const {},
Set<String> delivered = const {},
}) => riderStageOf(
row(status),
arrivedIds: arrived,
collectedIds: collected,
outForDeliveryIds: released,
deliveredIds: delivered,
);
group('1 · the six stages, walked once in order', () {
test('Pending — the offer', () {
expect(stage('miler_assigned'), RiderStage.pending);
expect(stage('pending'), RiderStage.pending);
});
test('Accepted — pickup_scheduled, and nothing else', () {
// The backend's own word for "the rider took it".
expect(stage('pickup_scheduled'), RiderStage.accepted);
});
test(
'Arrived — the arrival record, because no server field carries it',
() {
// `/reached` writes a timestamp; `GET /miler/bookings` does not return
// it. Until it does, the rider's own record is the only evidence there
// is — see `getArrivedOrderIds`.
expect(stage('pickup_scheduled', arrived: {id}), RiderStage.arrived);
},
);
test('Picked — either backend word, or the collected record', () {
expect(stage('converted_to_consignment'), RiderStage.picked);
expect(stage('picked_up'), RiderStage.picked);
expect(stage('pickup_scheduled', collected: {id}), RiderStage.picked);
});
test('Active — the delivery is under way', () {
expect(stage('out_for_delivery'), RiderStage.active);
expect(stage('active'), RiderStage.active);
expect(
stage('converted_to_consignment', released: {id}),
RiderStage.active,
reason: 'the rider pressed Start delivery; the round has begun',
);
});
test('Delivered — terminal', () {
expect(stage('delivered'), RiderStage.delivered);
});
test('Cancelled sits outside the run', () {
expect(stage('cancelled'), RiderStage.cancelled);
expect(stage('rejected'), RiderStage.cancelled);
});
test('the stages are ordered, and each is its own', () {
final walked = [
stage('miler_assigned'),
stage('pickup_scheduled'),
stage('pickup_scheduled', arrived: {id}),
stage('converted_to_consignment'),
stage('out_for_delivery'),
stage('delivered'),
];
expect(walked.toSet(), hasLength(6), reason: 'six distinct stages');
for (var i = 1; i < walked.length; i++) {
expect(
walked[i].rank,
greaterThan(walked[i - 1].rank),
reason: '${walked[i]} must rank above ${walked[i - 1]}',
);
}
});
});
group('2 · progress never runs backwards', () {
test('a stale arrival cannot un-pick a collected stop', () {
expect(
stage('converted_to_consignment', arrived: {id}),
RiderStage.picked,
);
});
test('a stale arrival cannot un-start a round', () {
expect(stage('out_for_delivery', arrived: {id}), RiderStage.active);
});
test('a stale collection cannot un-deliver an order', () {
expect(
stage('delivered', arrived: {id}, collected: {id}),
RiderStage.delivered,
);
});
test('no local record can revive a cancelled stop', () {
expect(
stage('cancelled', arrived: {id}, collected: {id}, released: {id}),
RiderStage.cancelled,
);
});
});
group('3 · the two resolvers answer different questions', () {
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
StopStatus pickupRung(String status, {Set<String> collected = const {}}) =>
MilkRun.stageOf(
row(status),
acceptedIds: const {},
collectedIds: collected,
);
test('delivery under way: Active overall, Picked on the pickup card', () {
// The case the separation exists for, stated in one test.
expect(stage('active', collected: {id}), RiderStage.active);
expect(pickupRung('active', collected: {id}), StopStatus.picked);
});
test('and the pickup milestone is readable from the stage too', () {
expect(stage('out_for_delivery', collected: {id}).pickupComplete, isTrue);
expect(stage('delivered').pickupComplete, isTrue);
expect(stage('pickup_scheduled', arrived: {id}).pickupComplete, isFalse);
expect(
stage('cancelled').pickupComplete,
isFalse,
reason: 'a cancelled stop was never picked',
);
});
test('availability is not a booking status and cannot reach either', () {
// `updateActiveStatus` posts `setAvailability('On_Pickup')` — a fact about
// the **rider**. It has no booking status to write, and the regression it
// caused was the app inventing one. Neither resolver takes availability,
// and there is no spelling of it that parses as a rung.
expect(stopStatusFromRaw('on_pickup'), StopStatus.unknown);
expect(stopStatusFromRaw('On_Pickup'), StopStatus.unknown);
});
});
group('3b · the authoritative contract, field by field', () {
Map<String, dynamic> booking(
String status, {
Object? reachedAt,
String? consignment,
}) => {
'orderid': id,
'pickupid': id,
'orderstatus': status,
if (reachedAt != null) 'reachedat': reachedAt,
if (consignment != null) 'consignmentstatus': consignment,
};
test('Pickup_Scheduled + no reachedat → Accepted', () {
expect(riderStageOf(booking('Pickup_Scheduled')), RiderStage.accepted);
expect(
riderStageOf(booking('Pickup_Scheduled', reachedAt: '')),
RiderStage.accepted,
reason: 'an empty stamp is not an arrival',
);
expect(
riderStageOf(booking('Pickup_Scheduled', reachedAt: 'null')),
RiderStage.accepted,
reason: 'a stringified JSON null is not an arrival either',
);
});
test('Pickup_Scheduled + reachedat → Arrived, from the server alone', () {
// No local record passed in: this is the rung rebuilt from server data,
// which is what retires the compatibility store.
expect(
riderStageOf(
booking('Pickup_Scheduled', reachedAt: '2026-08-25T12:33:02+05:30'),
),
RiderStage.arrived,
);
});
test('a malformed stamp is still an arrival, and does not throw', () {
// Read for presence, not for value: the backend does not write the field
// for a booking nobody arrived at, so an unparseable one is a bad clock
// rather than a bad claim. Parsing it and discarding it would lose the
// rung over a formatting detail.
for (final junk in const ['not-a-date', 0, '2026-13-45', true]) {
expect(
riderStageOf(booking('Pickup_Scheduled', reachedAt: junk)),
RiderStage.arrived,
reason: 'reachedat="$junk" threw or was dropped',
);
}
});
test('Collected_By_Miler → Picked', () {
expect(
riderStageOf(
booking(
'Converted_To_Consignment',
consignment: 'Collected_By_Miler',
),
),
RiderStage.picked,
);
});
test('Out_for_Delivery → Active', () {
expect(
riderStageOf(
booking('Converted_To_Consignment', consignment: 'Out_for_Delivery'),
),
RiderStage.active,
);
});
test('Delivered → Delivered', () {
expect(
riderStageOf(
booking('Converted_To_Consignment', consignment: 'Delivered'),
),
RiderStage.delivered,
);
});
test('the consignment outranks a stale stamp at every rung', () {
const stamp = '2026-08-25T12:33:02+05:30';
for (final (state, expected) in const [
('Collected_By_Miler', RiderStage.picked),
('Out_for_Delivery', RiderStage.active),
('Delivered', RiderStage.delivered),
]) {
expect(
riderStageOf(
booking('Pickup_Scheduled', reachedAt: stamp, consignment: state),
arrivedIds: {id},
collectedIds: {id},
),
expected,
reason: '$state was dragged back by older evidence',
);
}
});
test('CROSS-CITY · the hub\'s rungs are not this rider being Active', () {
// Confirmed by the backend: a cross-city parcel goes
// Collected_By_Miler → Inwarded_at_Hub → Tripsheet_Loaded → In_Transit
// and is delivered by somebody else. The rider's involvement ended at
// the collection, so Picked is where he stays — and no delivery control
// may be offered for a stop he cannot act on.
for (final hub in const [
'Inwarded_at_Hub',
'Tripsheet_Loaded',
'In_Transit',
'Created',
]) {
final at = riderStageOf(
booking('Converted_To_Consignment', consignment: hub),
);
expect(
at,
RiderStage.picked,
reason: '"$hub" resolved to $at — the hub half is not his round',
);
expect(at, isNot(RiderStage.active));
}
});
});
group('4 · restored from persisted state after a restart', () {
// Reconstruction reads the same evidence the live flow writes, so a
// relaunch lands a stop exactly where the rider left it — the one thing a
// derived lifecycle has to get right, because nothing is stored.
test('pickup_scheduled with no local record → Accepted', () {
expect(stage('pickup_scheduled'), RiderStage.accepted);
});
test('pickup_scheduled with a surviving arrival record → Arrived', () {
expect(stage('pickup_scheduled', arrived: {id}), RiderStage.arrived);
});
test('converted_to_consignment → Picked, with no local record at all', () {
expect(stage('converted_to_consignment'), RiderStage.picked);
});
test('a released round comes back Active', () {
expect(
stage('converted_to_consignment', released: {id}),
RiderStage.active,
);
});
test('a finished stop comes back Delivered', () {
expect(stage('delivered'), RiderStage.delivered);
});
test('nothing restores to an earlier rung than it reached', () {
// Every server word, with a full set of stale local records attached.
// None may pull the stop back.
final stale = {id};
for (final (said, expected) in const [
('converted_to_consignment', RiderStage.picked),
('picked_up', RiderStage.picked),
('out_for_delivery', RiderStage.active),
('active', RiderStage.active),
('delivered', RiderStage.delivered),
]) {
expect(
riderStageOf(row(said), arrivedIds: stale),
expected,
reason: '"$said" was dragged back by a stale arrival record',
);
}
});
});
}

View File

@@ -28,6 +28,8 @@ import 'package:miler/views/Dashboard/home/trip.dart';
/// says so.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
_adapterCarriesTheStamp();
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
@@ -198,7 +200,7 @@ void main() {
});
test('each source says out loud what it is', () {
expect(RouteOrderSource.adminSequence.label, 'Hub route');
expect(RouteOrderSource.adminSequence.label, 'Assigned route');
expect(RouteOrderSource.proximity.label, 'Nearest first');
expect(
RouteOrderSource.proximity.explanation,
@@ -351,3 +353,51 @@ void main() {
});
});
}
/// ── The adapter has to carry the stamp, or the contract is unreadable ──
///
/// `RouteOrder` gates on `sequencedat` exactly as the backend specified. That
/// is worth nothing if the field never reaches it: `pickupFromBooking` mapped
/// `step`, `stoptype`, `etaminutes`, `cumulativekms` and `cumulativeeta` and
/// dropped the one that says whether any of them mean anything — so every
/// adapted row looked unsequenced and the app fell back to nearest-first on
/// routes the hub had solved.
void _adapterCarriesTheStamp() {
group('the adapter carries sequencedat', () {
Map<String, dynamic> booking(Map<String, dynamic> extra) => {
'bookingid': 1,
'bookingno': 'B1',
'pickupaddress': '19 Thadagam Road',
...extra,
};
test('a sequenced row arrives sequenced', () {
final row = ApiConfig.pickupFromBooking(
booking({'step': 2, 'sequencedat': '2026-08-25T09:00:00+05:30'}),
);
expect(RouteOrder.isSequenced(row), isTrue);
expect(RouteOrder.sequenceOf(row), 2);
});
test('step 0 with no stamp is not a route, and that is not a bug', () {
// The backend's own words: `step: 0` + `sequencedat: null` means the
// rider holds fewer than two active stops, or the stop has no coords.
final row = ApiConfig.pickupFromBooking(
booking({'step': 0, 'sequencedat': null}),
);
expect(RouteOrder.isSequenced(row), isFalse);
expect(
RouteOrder.hasAdminSequence([row]),
isFalse,
reason: 'nearest-first is the correct answer here, not a fallback bug',
);
});
test('an empty stamp is no stamp', () {
final row = ApiConfig.pickupFromBooking(
booking({'step': 0, 'sequencedat': ' '}),
);
expect(RouteOrder.isSequenced(row), isFalse);
});
});
}

View File

@@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/helpers/widgets/slide_action.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/accepted_store.dart';
import 'package:miler/data/service_profile.dart';
import 'package:slide_to_submit_button/slide_to_submit_button.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/bag_manifest.dart';
@@ -355,18 +356,21 @@ void main() {
// file silently. The gesture goes dead and the sheet says why.
expect(find.text('Nothing to collect'), findsOneWidget);
expect(
find.text('Nothing was handed over — call the hub before you leave.'),
find.text('Nothing was handed over — call your office before you leave.'),
findsOneWidget,
);
final gate = tester.widget<IgnorePointer>(
find
.ancestor(
of: find.byType(SlideToSubmit),
matching: find.byType(IgnorePointer),
)
.first,
// ── Disabled is a different shape, not a dimmed one ──
//
// It used to be the slide control wrapped in an `IgnorePointer`, which
// is a control that still looks draggable and silently is not: the rider
// drags, nothing happens, and he learns the sheet is broken. With
// nothing to collect there is no slide control at all — just an inert
// bar carrying the reason.
expect(
find.byType(MilerSlideAction),
findsNothing,
reason: 'a gesture that cannot fire must not be offered',
);
expect(gate.ignoring, isTrue);
});
testWidgets('the outcome splits what he took from what he did not', (

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 317 KiB

View File

@@ -98,27 +98,65 @@ void main() {
);
});
test('Arrived_At_Pickup is what confirms it', () async {
// ── What confirms an arrival changed, on the backend's word ──
//
// This demanded `status: "Arrived_At_Pickup"`. The backend team has since
// confirmed there is **no Arrived rung in the booking-status lifecycle**:
// it runs `pickup_scheduled → converted_to_consignment → …`, and `/reached`
// records the arrival beside it as a timestamp.
//
// So the old assertion demanded evidence of a transition the backend never
// claimed to make, and `reached` answered *unconfirmed* forever — logging a
// gap against a healthy endpoint on every arrival. The stamp is the proof;
// the status echoing `pickup_scheduled` is correct and expected.
test('the arrival stamp is what confirms it', () async {
stub({
'/reached': jsonRes({
'success': true,
'data': {'bookingid': 78, 'status': 'Arrived_At_Pickup'},
'data': {
'bookingid': 78,
'status': 'pickup_scheduled',
'reachedat': '2026-08-25T12:33:02+05:30',
},
}),
});
final t = MilerLifecycle.reached(await MilerApi.reached(78));
expect(t.isConfirmed, isTrue);
expect(t.bookingStatus, BookingStatus.arrivedAtPickup);
expect(ApiConfig.legacyStatusFromNew('Arrived_At_Pickup'), 'arrived');
expect(
t.evidence,
contains('2026-08-25T12:33:02+05:30'),
reason: 'the log line has to say what the proof actually was',
);
// The status is untouched and unremarkable — that is the point.
expect(t.bookingStatus, isNot(BookingStatus.unknown));
});
test('a response naming no status at all is not confirmation', () async {
test('a stamped arrival confirms whatever the status says', () async {
// Including the old spelling, if a deployment still sends it: the stamp
// is the evidence either way.
stub({
'/reached': jsonRes({
'success': true,
'data': {
'status': 'Miler_Assigned',
'reached_at': '2026-08-25T12:33:02Z',
},
}),
});
expect(
MilerLifecycle.reached(await MilerApi.reached(78)).isConfirmed,
isTrue,
);
});
test('a response with no arrival stamp is not confirmation', () async {
stub({
'/reached': jsonRes({'success': true, 'data': {}}),
});
final t = MilerLifecycle.reached(await MilerApi.reached(78));
expect(t.isUnconfirmed, isTrue);
expect(t.evidence, contains('no status'));
expect(t.evidence, contains('no arrival stamp'));
});
test('a refusal is a refusal, with its code', () async {

View File

@@ -155,11 +155,31 @@ void main() {
);
});
test('and arriving is what puts him on the stop', () {
test('and arriving is its own rung, not "active"', () {
// ── The two used to be one, and it cost the rider his arrival ──
//
// `stopStateOf` folded `arrived` into [StopState.active], so the Home row
// had no arrived to render and the live chip printed the literal word
// **Active** the moment he slid *confirm arrival*. The rung had moved,
// `/reached` had fired and the local record was written — and the one
// word on screen said something else, which is indistinguishable from
// the arrival not registering.
//
// They are different facts: arrived is a rung on the pickup ladder, and
// active is the backend's word for a delivery under way.
final stop = {'orderid': 'A1', 'orderstatus': 'arrived'};
expect(
stopStateOf(stop, acceptedIds: const {}, rejectedIds: const {}),
StopState.arrived,
);
expect(
stopStateOf(
{'orderid': 'A1', 'orderstatus': 'active'},
acceptedIds: const {},
rejectedIds: const {},
),
StopState.active,
reason: 'and active still means active',
);
});

View File

@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/miler_surface.dart';
import 'package:miler/views/helpers/constants/narrative.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE LAYERS HAVE TO BE VISIBLE TO BE LAYERS
@@ -40,20 +41,35 @@ double contrast(Color a, Color b) {
void main() {
group('the ladder has real rungs', () {
test('a working surface separates from the canvas without a border', () {
// ── This rung is now bought two ways, not one ──
//
// The old contract was: the tonal step alone has to carry the separation,
// because a card had no border and no shadow — so anything under ~1.25:1
// meant screens would start drawing their own outlines to compensate.
//
// Cards carry a hairline *and* a soft lift now (see [MilerPanel] and
// [Narrative]), which is the brief's recipe rather than this one, and the
// canvas went light to match. The step is deliberately smaller; what must
// not happen is that it goes to nothing, because then the border is doing
// the whole job and a card on a page reads as a box drawn on paper.
test('a working surface still separates from the canvas', () {
final step = contrast(MilerSurface.working, MilerSurface.canvas);
expect(
step,
greaterThanOrEqualTo(1.25),
reason:
'white on the canvas is $step : 1 — below ~1.25 a surface does '
'not read as a surface, and screens start drawing their own '
'outlines to compensate',
greaterThan(1.0),
reason: 'white on the canvas is $step : 1 — the ground has vanished',
);
// And not so far that the page turns into a dark frame around content.
expect(step, lessThan(1.6));
});
test('a card carries the edge and the lift the ground no longer does', () {
// The other half of the trade above. If either of these is dropped, the
// canvas has to go back to carrying the step on its own.
expect(Narrative.cardShadow, isNotEmpty);
expect(Narrative.cardBorder, isNot(Narrative.card));
});
test('the canvas is never white', () {
expect(MilerSurface.canvas, isNot(MilerSurface.working));
expect(MilerSurface.canvas, isNot(const Color(0xFFFFFFFF)));

View File

@@ -225,6 +225,55 @@ void main() {
final p = await resolveWith({'tenantname': 'nobody', 'tenantid': 4242});
expect(p.line, ServiceLine.parcel);
});
test('a known tenant id outranks a name that contradicts it', () async {
// ── The loose end in a one-shot global flag ──
//
// Tenant 13 is the meal client, and its display name is whatever was
// typed into the tenant record — plausibly the owning company's. A name
// normalising to `doormile` matched the Logistics list, won on
// name-first, and the id was never consulted.
//
// On Logistics `MyPickups._unreleased` is empty by design, so the
// **Start round** bar does not render. With
// `MILER_COLLECTED_STATE_ENABLED` on, that rider's collected parcels
// strand at `Collected_By_Miler` with no control to release them.
for (final name in const ['Doormile', 'doormile', 'DOOR-MILE']) {
final p = await resolveWith({'tenantname': name, 'tenantid': 13});
expect(
p.line,
ServiceLine.milkMan,
reason: 'tenant 13 is the meal client whatever it is called ($name)',
);
}
});
test('a long legal name is harmless — it simply is not an answer', () async {
// The case the backend team asked about. It matches neither list, so it
// falls through to the id exactly as an empty name would.
expect(
TenantController.profileForName('Daily Grubs Foods Pvt Ltd'),
isNull,
);
final p = await resolveWith({
'tenantname': 'Daily Grubs Foods Pvt Ltd',
'tenantid': 13,
});
expect(p.line, ServiceLine.milkMan);
});
test('a tenant this build has never heard of is still decided by name',
() async {
// The property name-first existed for, and it must survive the change
// above: a NEW meal client, absent from the id list, costs no release.
final p = await resolveWith({'tenantname': 'milkrun', 'tenantid': 999});
expect(p.line, ServiceLine.milkMan);
// And the mirror — a new logistics client is not dragged onto the meal
// line by an id nobody has listed.
final q = await resolveWith({'tenantname': 'logistics', 'tenantid': 998});
expect(q.line, ServiceLine.parcel);
});
});
group('what each mode can and cannot do', () {

View File

@@ -0,0 +1,121 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE TENANT, REFRESHED WITHOUT A SIGN-OUT
///
/// `tenantname` was written at verify-pin and nowhere else, so it only reached
/// a device by way of a **login**. A rider already signed in when the backend
/// began returning the field would never receive it: prefs carry no name,
/// resolution falls back to the tenant id, and an id this app has not been told
/// about lands him on logistics — no **Start delivery** button, and a parcel
/// stranded at `Collected_By_Miler` the moment collected-state is enabled.
///
/// `GET /miler/profile` returns the same pair, so the answer is refreshable in
/// place. These pin that, and pin the three ways it must fail quietly: a launch
/// with no signal, a signed-out device, and a handler that answers without the
/// field.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
tearDown(() {
MilerApi.client = http.Client();
ServiceProfile.setActive(ServiceProfile.parcel);
});
void stubProfile(Map<String, dynamic> body, {int code = 200}) {
MilerApi.client = MockClient(
(req) async => http.Response(
json.encode(body),
code,
headers: const {'content-type': 'application/json'},
),
);
}
test('a signed-in rider picks up his tenant name in place', () async {
SharedPreferences.setMockInitialValues({'authtoken': 'tok'});
stubProfile({
'success': true,
'data': {'tenantid': 41, 'tenantname': 'DailyGrubs'},
});
await refreshTenantFromProfile();
expect(
await resolveServiceProfile(),
ServiceProfile.milkMan,
reason: 'no sign-out should be required to receive the tenant',
);
});
test('the pair is read from inside `user` as well as top level', () async {
// The backend returns it both ways; a handler that later moves it must not
// silently stop resolving riders.
SharedPreferences.setMockInitialValues({'authtoken': 'tok'});
stubProfile({
'success': true,
'data': {
'user': {'tenantid': 41, 'tenantname': 'Daily Grubs'},
},
});
await refreshTenantFromProfile();
expect(await resolveServiceProfile(), ServiceProfile.milkMan);
});
test('a signed-out device asks for nothing', () async {
var called = false;
MilerApi.client = MockClient((req) async {
called = true;
return http.Response('{}', 200);
});
SharedPreferences.setMockInitialValues(const {});
await refreshTenantFromProfile();
expect(called, isFalse, reason: 'no token, no request');
});
test('a launch with no signal keeps what it already had', () async {
SharedPreferences.setMockInitialValues({
'authtoken': 'tok',
TenantController.kTenantName: 'DailyGrubs',
});
MilerApi.client = MockClient((req) async => throw const SocketishFailure());
// Must not throw: a launch offline lands on the same screen it always did.
await refreshTenantFromProfile();
expect(await resolveServiceProfile(), ServiceProfile.milkMan);
});
test('a response without the field does not erase the stored one', () async {
SharedPreferences.setMockInitialValues({
'authtoken': 'tok',
TenantController.kTenantName: 'DailyGrubs',
});
stubProfile({
'success': true,
'data': {'displayname': 'Rajan A'},
});
await refreshTenantFromProfile();
// Backgrounded for a known rider, so let it land before asserting.
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(await resolveServiceProfile(), ServiceProfile.milkMan);
});
}
/// A stand-in for a dead network, so the test does not depend on `dart:io`
/// error construction details.
class SocketishFailure implements Exception {
const SocketishFailure();
}

View File

@@ -0,0 +1,101 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// WHAT `tenantname` ON THE LOGIN IS WORTH
///
/// The backend gates `MILER_COLLECTED_STATE_ENABLED` on every hyperlocal rider
/// having a **Start delivery** button, because with the flag on a parcel with
/// nobody to release it sits at `Collected_By_Miler` forever.
///
/// That button is not a build flag. It is [ServiceProfile.deliversToCustomer],
/// resolved per-login from the rider's tenant — so the question is not *which
/// build is installed* but *does this rider resolve to the milk-run profile*.
///
/// ── Two ways to answer, and only one of them scales ──
///
/// ```
/// by id a hardcoded set, {13} today. A client whose id we were never
/// told falls through to logistics and gets no button.
/// by name matched against `tenantname`, checked FIRST. `dailygrubs` is
/// already on the list, so the client resolves with no release.
/// ```
///
/// `tenantname` is not deployed yet, which is the whole of the risk: every
/// hyperlocal tenant that is not id 13 is currently a strand waiting for the
/// flag to be turned on. These pin both halves — that the name is sufficient
/// on its own, and what happens without it.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
test(
'the name alone puts a DailyGrubs rider on the milk-run profile',
() async {
// Deliberately no tenant id: this is a client whose id this app has never
// been told, which is exactly the case the id set cannot cover.
SharedPreferences.setMockInitialValues({
TenantController.kTenantName: 'DailyGrubs',
});
final profile = await resolveServiceProfile();
expect(profile, ServiceProfile.milkMan);
expect(
profile.deliversToCustomer,
isTrue,
reason: 'this is the flag that renders Start delivery',
);
},
);
test('case and spacing do not decide a rider\'s day', () async {
for (final spelling in const [
'dailygrubs',
'DAILYGRUBS',
' DailyGrubs ',
// How a human actually types a client name into a tenant record. Each of
// these used to miss, and a miss is a rider with no Start delivery button
// and a parcel stranded at `Collected_By_Miler`.
'Daily Grubs',
'daily-grubs',
'DAILY_GRUBS',
]) {
SharedPreferences.setMockInitialValues({
TenantController.kTenantName: spelling,
});
expect(
await resolveServiceProfile(),
ServiceProfile.milkMan,
reason: '"$spelling" did not resolve',
);
}
});
test('without it, an unrecognised tenant falls back to logistics', () async {
// The strand: flag on, newest build installed, no button to press, and the
// parcel stays at `Collected_By_Miler` because nothing can release it.
SharedPreferences.setMockInitialValues({TenantController.kTenantId: 17});
final profile = await resolveServiceProfile();
expect(profile, ServiceProfile.parcel);
expect(profile.deliversToCustomer, isFalse);
});
test('the name decides when the id says nothing', () async {
// Belt and braces: a hyperlocal client sitting on an id this app has not
// been told about must still resolve from its name.
//
// Note 17 is *unlisted*, not contradictory — an id this build knows wins,
// see `tenant_mode_test.dart`. This is the fall-through, which is the whole
// reason a new client needs no release.
SharedPreferences.setMockInitialValues({
TenantController.kTenantName: 'DailyGrubs',
TenantController.kTenantId: 17,
});
expect(await resolveServiceProfile(), ServiceProfile.milkMan);
});
}

View File

@@ -78,14 +78,14 @@ void main() {
await pump(tester, route(4, 2, 11)); // 18 stops, current is #5
expect(find.text('4 of 18 done'), findsOneWidget);
expect(
find.text('On stop 5 of 18 · 2 skipped · 2h 10m left, then hub'),
find.text('On stop 5 of 18 · 2 skipped · 2h 10m left, then base'),
findsOneWidget,
);
});
testWidgets('drops the skip clause when nothing was skipped', (tester) async {
await pump(tester, route(3, 0, 5));
expect(find.text('On stop 4 of 9 · 2h 10m left, then hub'), findsOneWidget);
expect(find.text('On stop 4 of 9 · 2h 10m left, then base'), findsOneWidget);
});
// The card, its border, its shadow and its padding are gone — the rail sits
@@ -153,10 +153,10 @@ void main() {
expect(find.byIcon(LucideIcons.warehouse), findsNothing);
});
testWidgets('a finished route says head back to the hub', (tester) async {
testWidgets('a finished route says head back to base', (tester) async {
await pump(tester, route(6, 0, 0, current: false));
expect(find.text('All stops done'), findsOneWidget);
expect(find.text('Head back to the hub'), findsOneWidget);
expect(find.text('Head back to base'), findsOneWidget);
});
// The rail numbers the stops the same way the list below it does, and the

View File

@@ -5,60 +5,46 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/home/trip_tabs.dart';
/// The Trip 1 / 2 / 3 bar is a **pinned** sliver on Home: it scrolls up with the
/// page, then holds at the top while the stops pass underneath, so switching
/// trips never costs a scroll back up.
/// ─────────────────────────────────────────────────────────────────────────
/// THE SELECTOR STAYS PUT, AND IT STAYS ONE HEIGHT
///
/// Two things here are worth a test.
/// This file used to guard a pinned `SliverPersistentHeader`: the delegate had
/// to declare a fixed extent, [TripSelector]'s content was type that grows with
/// the system font, and the two disagreed by a few pixels at large settings —
/// so `extentFor` derived an over-estimate and these tests measured it from
/// 1.0× to 2.0× to prove it was never short.
///
/// **The extent is computed, and computed height is the classic way to clip a
/// control.** A pinned sliver must declare a fixed height, and [TripTabs]
/// deliberately has none — its own comment records that a hard `54.h` overflowed
/// by a few pixels at large font settings. `extentFor` therefore derives the
/// height from the text scale and over-estimates on purpose. If that estimate is
/// ever short, the tabs are clipped inside the header, silently. So it is
/// measured here from 1.0× to 2.0×.
/// **None of that exists any more.** The selector moved into the brand header,
/// which is fixed by being the Column's first child rather than by being a
/// sliver, so there is no extent to declare and nothing to under-estimate.
///
/// **Pinning itself.** That the bar holds position while content scrolls under
/// it is the entire point of the change, and a one-word edit (`pinned: false`)
/// undoes it invisibly.
/// What survives is the property the arithmetic was protecting, and it is now
/// the widget's own: the control is a **fixed height** that does not move with
/// the text scale. A header that grows a few points at 1.5× pushes the whole
/// page down and re-lays the route out under the rider's thumb. It cannot,
/// because nothing in the control is free-flowing type — the numeral is inside
/// a `FittedBox` that scales it down into the space it has.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
// The view is forced to 390x844 logical below, so gestures must aim inside
// that box — x=400 would land outside the viewport and hit nothing.
const inViewport = Offset(195, 500);
Map<String, dynamic> stop(String id) => {
'orderid': id,
'pickupid': id,
'pickupcustomer': 'Sri Balaji Stores',
'pickupaddress': '19, Thadagam Road, Coimbatore',
'orderstatus': 'pending',
};
const aboveKey = ValueKey('above');
const belowKey = ValueKey('below');
final trips = [
Trip.fromStops(
id: 't1',
stops: [
{'orderid': 'A', 'type': 'pickup', 'pickupcustomer': 'Sri Balaji'},
],
),
Trip.fromStops(
id: 't2',
stops: [
{'orderid': 'B', 'type': 'delivery', 'pickupcustomer': 'Anand'},
],
),
];
/// Pumps the real delegate inside a scroll view, at a given text scale, and
/// hands back the extent it reserved.
Future<double> pump(
Future<void> pump(
WidgetTester tester, {
required int trips,
int selected = 0,
double textScale = 1.0,
ValueChanged<int>? onSelect,
}) async {
tester.view.physicalSize = const Size(1170, 2532);
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
double? captured;
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
@@ -70,142 +56,123 @@ void main() {
child: child!,
),
home: Scaffold(
body: Builder(
builder: (context) {
captured = TripTabsHeader.extentFor(context);
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Container(
key: aboveKey,
height: 300,
color: Colors.amber,
),
),
SliverPersistentHeader(
pinned: true,
delegate: TripTabsHeader(
extent: captured!,
trips: trips,
selectedIndex: 1,
onSelect: onSelect ?? (_) {},
),
),
SliverToBoxAdapter(
child: Container(
key: belowKey,
height: 2000,
color: Colors.grey,
),
),
],
);
},
body: Padding(
padding: const EdgeInsets.all(16),
child: TripSelector(
trips: [
for (var i = 0; i < trips; i++)
Trip.fromStops(id: 't$i', stops: [stop('$i')]),
],
selectedIndex: selected,
onSelect: (_) {},
),
),
),
),
),
);
await tester.pumpAndSettle();
return captured!;
await tester.pump();
}
group('reserved extent fits the tabs', () {
// 2.0 is beyond Android's slider but within what a determined user can set,
// and it is the case the original 54.h bug was reported against.
for (final scale in [1.0, 1.15, 1.3, 1.5, 1.75, 2.0]) {
testWidgets('at ${scale}x text scale', (tester) async {
final extent = await pump(tester, textScale: scale);
group('the control is one height at every text scale', () {
for (final scale in const [1.0, 1.3, 1.5, 2.0]) {
testWidgets('at ${scale}x', (tester) async {
await pump(tester, trips: 2, textScale: scale);
await tester.pump(const Duration(milliseconds: 400));
expect(
tester.takeException(),
isNull,
reason: 'the tabs must not overflow the reserved extent',
);
final tabs = tester.getSize(find.byType(TripTabs));
expect(
tabs.height,
lessThanOrEqualTo(extent + 0.01),
tester.getSize(find.byType(TripSelector)).height,
TripSelector.height,
reason:
'tabs are ${tabs.height} tall but only $extent was '
'reserved — the bar would be clipped',
'the header must not change height with the font, or the whole '
'route below it moves under the rider\'s thumb',
);
// Over-reserving is the safe direction, but not by a wasteful amount.
expect(
extent - tabs.height,
lessThan(14),
reason:
'reserving $extent for ${tabs.height} of content is '
'more dead space than the safety margin intends',
);
// All three slots are still rendered and readable at this scale.
for (final label in ['Trip 1', 'Trip 2', 'Trip 3']) {
expect(find.text(label), findsOneWidget);
}
expect(tester.takeException(), isNull);
});
}
});
group('pinning', () {
testWidgets('the bar holds at the top while content scrolls under it', (
tester,
) async {
await pump(tester);
final atRest = tester.getRect(find.byType(TripTabs));
// It starts below the 300pt block above it, not stuck to the top.
expect(atRest.top, greaterThan(200));
testWidgets('three slots render before the hub has filled them', (
tester,
) async {
await pump(tester, trips: 1);
for (final n in ['Trip 1', 'Trip 2', 'Trip 3']) {
expect(find.text(n), findsOneWidget);
}
});
await tester.dragFrom(inViewport, const Offset(0, -600));
await tester.pumpAndSettle();
testWidgets('exactly one segment is marked, and it is the selected one', (
tester,
) async {
await pump(tester, trips: 2, selected: 1);
await tester.pump(const Duration(milliseconds: 400));
final pinned = tester.getRect(find.byType(TripTabs));
expect(
pinned.top,
lessThan(atRest.top),
reason: 'it should travel up with the page first',
);
expect(
pinned.top,
lessThan(40),
reason: 'and then hold near the top of the viewport',
);
final marks = find.byKey(TripSelector.markerKey);
expect(marks, findsOneWidget, reason: 'one trip is current, not two');
// Scrolling further does not move it again.
await tester.dragFrom(inViewport, const Offset(0, -600));
await tester.pumpAndSettle();
expect(tester.getRect(find.byType(TripTabs)), pinned);
});
// The rule is a short centred mark under its pill rather than a full-width
// underline, so what has to hold is that it is centred on the *right*
// pill — not that it spans it.
final marker = tester.getRect(marks);
final second = tester.getRect(find.text('Trip 2'));
expect(
marker.center.dx,
closeTo(second.center.dx, 6),
reason: 'the rule sits under the pill it belongs to',
);
expect(
marker.top,
greaterThan(second.bottom),
reason: 'and under it, not through it',
);
});
testWidgets('what sits above it does scroll away', (tester) async {
await pump(tester);
expect(find.byKey(aboveKey), findsOneWidget);
testWidgets('the indicator is a rule, not a filled tab', (tester) async {
// The tabs sit straight on the brand ground: no card, no wash, no outline.
// Selection is the travelling rule and the ink weight, and nothing else.
await pump(tester, trips: 2);
await tester.pump(const Duration(milliseconds: 400));
await tester.dragFrom(inViewport, const Offset(0, -900));
await tester.pumpAndSettle();
final marker = tester.getSize(find.byKey(TripSelector.markerKey));
expect(
marker.height,
lessThanOrEqualTo(4),
reason: 'a 2-3dp rule, not a segment-sized fill',
);
});
// The summary / shift / metrics block is meant to leave; only the control
// stays. A sliver that has left the viewport and its cache is unmounted.
expect(find.byKey(aboveKey), findsNothing);
expect(find.byType(TripTabs), findsOneWidget);
});
testWidgets('a segment is tappable and reports its own index', (
tester,
) async {
int? chosen;
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
testWidgets('a tab is still tappable once pinned', (tester) async {
var picked = -1;
await pump(tester, onSelect: (i) => picked = i);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: Padding(
padding: const EdgeInsets.all(16),
child: TripSelector(
trips: [
Trip.fromStops(id: 't0', stops: [stop('0')]),
Trip.fromStops(id: 't1', stops: [stop('1')]),
],
selectedIndex: 0,
onSelect: (i) => chosen = i,
),
),
),
),
),
);
await tester.pump();
await tester.dragFrom(inViewport, const Offset(0, -900));
await tester.pumpAndSettle();
await tester.tap(find.text('Trip 1'));
await tester.pumpAndSettle();
expect(
picked,
0,
reason: 'switching trips from deep in the list is the whole point',
);
});
await tester.tap(find.text('Trip 2'));
expect(chosen, 1);
});
}

View File

@@ -458,7 +458,9 @@ void main() {
acceptedIds: const {},
rejectedIds: const {},
),
StopState.active,
// Its own rung since the live chip stopped printing `Active` over it —
// see [StopState.arrived].
StopState.arrived,
);
expect(
stopStateOf(
@@ -657,7 +659,9 @@ void main() {
acceptedIds: const {},
rejectedIds: {'a'},
),
StopState.active,
// Arrived is its own rung now — see [StopState.arrived]. What the
// test is about is unchanged: a server rung outranks a local decision.
StopState.arrived,
);
});

View File

@@ -83,7 +83,7 @@ void main() {
group('a rider who logs in on a meal tenant', () {
setUp(() => ServiceProfile.setActive(ServiceProfile.meals));
testWidgets('starts at a kitchen, and never comes back to a hub', (
testWidgets('starts at a kitchen, and never comes back to base', (
tester,
) async {
await pumpRoute(
@@ -104,8 +104,8 @@ void main() {
// His day ends at the last subscriber's door. A return leg to a warehouse
// he has never seen is an un-completable step at the end of a route he
// has in fact finished.
expect(find.text('RETURN · HUB'), findsNothing);
expect(find.text('START · HUB'), findsNothing);
expect(find.text('RETURN · BASE'), findsNothing);
expect(find.text('START · BASE'), findsNothing);
});
testWidgets('sees his route grouped under the kitchen it comes from', (
@@ -172,7 +172,7 @@ void main() {
group('a rider who logs in without a meal tenant', () {
setUp(() => ServiceProfile.setActive(ServiceProfile.parcel));
testWidgets('gets the hub-to-hub route he has always had', (tester) async {
testWidgets('gets the base-to-base route he has always had', (tester) async {
await pumpRoute(
tester,
stops: [
@@ -185,7 +185,7 @@ void main() {
// customer doors and comes back. The head is shared with the meal run;
// the closing leg is what tells the two apart.
expect(find.text('TODAY’S RUN'), findsOneWidget);
expect(find.text('RETURN · HUB'), findsOneWidget);
expect(find.text('RETURN · BASE'), findsOneWidget);
expect(find.text('START · KITCHEN'), findsNothing);
});
@@ -324,7 +324,7 @@ void main() {
testWidgets('a parcel day is a loop, and says so', (tester) async {
ServiceProfile.setActive(ServiceProfile.parcel);
final line = await subtitle(tester);
expect(line, contains('then hub'));
expect(line, contains('then base'));
});
});
}