Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
259 lines
9.7 KiB
Dart
259 lines
9.7 KiB
Dart
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:miler/views/Dashboard/profile/informations/faq.dart';
|
||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||
import 'package:miler/views/helpers/constants/miler_type.dart';
|
||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||
|
||
/// The pages you can navigate *into* from Account.
|
||
///
|
||
/// ── Why these needed a test ──
|
||
///
|
||
/// Eight pushed pages had six different app bars between them: four solid brand
|
||
/// red with white text, two white with black text, one grey, one white with a
|
||
/// red title. The back arrow came in four different icons at four sizes in three
|
||
/// colours. Two shared helpers already existed and, between them, were used by
|
||
/// three pages.
|
||
///
|
||
/// And the FAQ page **overflowed at any text scale at or above 1.5** — three
|
||
/// separate places, on every screen width — which is a black-and-yellow band
|
||
/// across a help page for the riders most likely to need help reading it.
|
||
///
|
||
/// Both classes of bug are invisible in a screenshot taken at default settings
|
||
/// on the developer's own phone, so both are measured here instead.
|
||
void main() {
|
||
Future<void> pump(
|
||
WidgetTester tester,
|
||
Widget page, {
|
||
double width = 390,
|
||
double textScale = 1.0,
|
||
}) async {
|
||
tester.view.physicalSize = Size(width * 3, 800 * 3);
|
||
tester.view.devicePixelRatio = 3.0;
|
||
addTearDown(tester.view.resetPhysicalSize);
|
||
addTearDown(tester.view.resetDevicePixelRatio);
|
||
|
||
await tester.pumpWidget(
|
||
ScreenUtilInit(
|
||
designSize: const Size(390, 844),
|
||
builder: (_, _) => MaterialApp(
|
||
builder: (context, child) => MediaQuery(
|
||
data: MediaQuery.of(
|
||
context,
|
||
).copyWith(textScaler: TextScaler.linear(textScale)),
|
||
child: child!,
|
||
),
|
||
home: page,
|
||
),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
}
|
||
|
||
group('the shared pushed-page bar', () {
|
||
// ── The wash gave way to the brand, everywhere at once ──
|
||
//
|
||
// This bar wore a frosted 7% brand wash flattened against white while Home
|
||
// and the four tabs are solid brand. So pushing Edit Profile, Saved
|
||
// Addresses, Alert Sound or Notifications changed the colour of the top of
|
||
// the phone AND the colour of the status-bar glyphs — which reads as
|
||
// leaving the app rather than opening a page inside it.
|
||
//
|
||
// One ground now, and everything on it inverts. The contract this test
|
||
// 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 {
|
||
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',
|
||
);
|
||
expect(
|
||
bar.systemOverlayStyle?.statusBarIconBrightness,
|
||
Brightness.light,
|
||
reason:
|
||
'dark glyphs on maroon is the one contrast failure a rider '
|
||
'cannot work around by tilting the phone',
|
||
);
|
||
|
||
final title = tester.widget<Text>(find.text('Saved Addresses'));
|
||
expect(title.style!.color, Colors.white);
|
||
expect(
|
||
title.style!.fontSize,
|
||
MilerType.pageTitleFixed.fontSize,
|
||
reason: 'one page-title size across tabs and pushed screens',
|
||
);
|
||
|
||
// The back control inverts with it, or it disappears into the ground.
|
||
final back = tester.widget<Icon>(find.byIcon(LucideIcons.arrowLeft));
|
||
expect(back.color, Colors.white);
|
||
});
|
||
|
||
testWidgets('carries a back affordance when given one', (tester) async {
|
||
var popped = false;
|
||
await pump(
|
||
tester,
|
||
Scaffold(
|
||
appBar: milerPageBar('Notifications', onBack: () => popped = true),
|
||
),
|
||
);
|
||
|
||
// ── An arrow, because these pages are pushed ──
|
||
//
|
||
// The cross promised dismissal on nine screens you actually go *back*
|
||
// from, and it was one of three back glyphs in the app. `close_rounded`
|
||
// survives behind `isModal: true`, for a sheet that genuinely dismisses.
|
||
expect(find.byIcon(LucideIcons.arrowLeft), findsOneWidget);
|
||
expect(find.byIcon(LucideIcons.x), findsNothing);
|
||
|
||
await tester.tap(find.byIcon(LucideIcons.arrowLeft));
|
||
await tester.pumpAndSettle();
|
||
expect(popped, isTrue);
|
||
});
|
||
|
||
testWidgets('and none when it is not', (tester) async {
|
||
await pump(tester, Scaffold(appBar: milerPageBar('Terms')));
|
||
expect(find.byIcon(LucideIcons.arrowLeft), findsNothing);
|
||
expect(find.byIcon(LucideIcons.x), findsNothing);
|
||
});
|
||
|
||
testWidgets('a modal presentation still gets a cross', (tester) async {
|
||
await pump(
|
||
tester,
|
||
Scaffold(
|
||
appBar: milerPageBar('Skip stop', onBack: () {}, isModal: true),
|
||
),
|
||
);
|
||
expect(find.byIcon(LucideIcons.x), findsOneWidget);
|
||
expect(find.byIcon(LucideIcons.arrowLeft), findsNothing);
|
||
});
|
||
|
||
testWidgets('survives a long title and a large font', (tester) async {
|
||
await pump(
|
||
tester,
|
||
Scaffold(
|
||
appBar: milerPageBar(
|
||
'A Very Long Page Title That Will Not Fit',
|
||
onBack: () {},
|
||
),
|
||
),
|
||
width: 320,
|
||
textScale: 2.0,
|
||
);
|
||
expect(tester.takeException(), isNull);
|
||
});
|
||
|
||
// ── One line, not two ──
|
||
//
|
||
// The bar used to carry a caption under the title. It was the smallest type
|
||
// on the screen in the one place a rider reads exactly once, and every
|
||
// caption duplicated something the page said louder further down — two
|
||
// sources for one number, which is how they drift apart.
|
||
testWidgets('carries the page name and nothing under it', (tester) async {
|
||
await pump(tester, Scaffold(appBar: milerPageBar('Notifications')));
|
||
|
||
final texts = find.byType(Text);
|
||
expect(tester.widgetList<Text>(texts), hasLength(1));
|
||
expect(find.text('Notifications'), findsOneWidget);
|
||
});
|
||
|
||
// ── The × is stacked ABOVE the heading, on the same margin ──
|
||
//
|
||
// It used to share the row: `[×] Edit Profile`. That pushed every title on
|
||
// every pushed page 52pt in from the margin, out of line with the content
|
||
// beneath it, and a control sitting at a title's left at the same optical
|
||
// weight reads as part of the title rather than as something you press.
|
||
testWidgets('stacks the close above the title', (tester) async {
|
||
await pump(
|
||
tester,
|
||
Scaffold(appBar: milerPageBar('Edit Profile', onBack: () {})),
|
||
);
|
||
|
||
final close = tester.getRect(find.byIcon(LucideIcons.arrowLeft));
|
||
final title = tester.getRect(find.text('Edit Profile'));
|
||
|
||
expect(
|
||
close.bottom,
|
||
lessThanOrEqualTo(title.top),
|
||
reason: 'the back control must sit on its own row above the heading',
|
||
);
|
||
expect(
|
||
close.left,
|
||
closeTo(title.left, 0.5),
|
||
reason:
|
||
'they share the page margin — an IconButton could not do this, '
|
||
'which is why the close is hand-rolled. See _TopBarClose.',
|
||
);
|
||
});
|
||
|
||
testWidgets('gives the back control a full 48pt target', (tester) async {
|
||
await pump(
|
||
tester,
|
||
Scaffold(appBar: milerPageBar('Edit Profile', onBack: () {})),
|
||
);
|
||
final box = tester.getSize(
|
||
find
|
||
.ancestor(
|
||
of: find.byIcon(LucideIcons.arrowLeft),
|
||
matching: find.byType(SizedBox),
|
||
)
|
||
.first,
|
||
);
|
||
// 48, not the 44pt accessibility floor: this is the control a rider hits
|
||
// one-handed, and the glyph inside stays 24 so only the target grew.
|
||
expect(box.width, 48);
|
||
expect(box.height, 48);
|
||
});
|
||
});
|
||
|
||
group('FAQ lays out at every font scale', () {
|
||
// It did not. `Container(height: 160.h)` around the hero overflowed by 89px
|
||
// at 1.5x; the category grid's `childAspectRatio` derived cell height from
|
||
// the column *width*, so all four tiles overflowed by ~10px; and the SOS
|
||
// row's two unflexed children ran 30px past the card.
|
||
for (final width in [320.0, 360.0, 390.0, 430.0]) {
|
||
for (final scale in [1.0, 1.3, 1.5, 2.0]) {
|
||
testWidgets('at ${width.toInt()}pt / ${scale}x', (tester) async {
|
||
await pump(tester, const FaqPage(), width: width, textScale: scale);
|
||
expect(
|
||
tester.takeException(),
|
||
isNull,
|
||
reason: 'FAQ overflowed at ${width.toInt()}pt / ${scale}x',
|
||
);
|
||
|
||
// Opening the accordions is where the page grows the most.
|
||
final tiles = find.byType(ExpansionTile);
|
||
for (var i = 0; i < tiles.evaluate().length; i++) {
|
||
await tester.tap(tiles.at(i), warnIfMissed: false);
|
||
await tester.pumpAndSettle();
|
||
expect(
|
||
tester.takeException(),
|
||
isNull,
|
||
reason:
|
||
'FAQ overflowed opening accordion $i at '
|
||
'${width.toInt()}pt / ${scale}x',
|
||
);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// Terms & Conditions is deliberately not laid out here: it hosts a
|
||
// `WebView`, and `WebViewPlatform.instance` has no implementation in a headless
|
||
// unit test, so the page cannot be built at all. Its app bar is the shared one
|
||
// — that much is covered by the group above — but the body needs a device or
|
||
// an integration test.
|
||
}
|