Book a pickup, track it to delivery — the rebuilt customer app. - Design language from doormile-screens.html: brand #8F0F06, Manrope + Geist Mono (variable fonts), bordered cards instead of shadows, crimson brand headers, sliding tab indicator, mono for anything read digit by digit. - lib/data (one live API implementation, plus a debug-only offline fake), lib/state, lib/ui (tokens, widgets, screens). - 84 tests, plus a design snapshot harness that renders every screen with the real fonts: flutter test test/design_snapshot_test.dart --run-skipped --update-goldens This replaces the previous app (pubspec 'doormile', app id com.doormile.customer). That tree remains in history at 6c7d656; note its android/app/google-services.json is not carried over, and the application id here is in.doormile.customer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
171 lines
5.7 KiB
Dart
171 lines
5.7 KiB
Dart
@Tags(['snapshot'])
|
||
library;
|
||
|
||
import 'package:doormile_cx/data/dev_doormile_api.dart';
|
||
import 'package:doormile_cx/data/doormile_api.dart';
|
||
import 'package:doormile_cx/data/location_service.dart';
|
||
import 'package:doormile_cx/main.dart';
|
||
import 'package:doormile_cx/state/app_state.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
|
||
/// Renders each screen at phone size and writes it to `test/snapshots/`.
|
||
///
|
||
/// This is a **design** check, not a regression check. A deliberate redesign
|
||
/// would fail every one of these, which is worthless as a test signal, so the
|
||
/// `snapshot` tag is skipped in a normal run (see `dart_test.yaml`) and the
|
||
/// pictures are rendered on demand:
|
||
///
|
||
/// flutter test test/design_snapshot_test.dart --run-skipped --update-goldens
|
||
///
|
||
/// It exists because the only honest way to review a visual rebuild is to look
|
||
/// at it, and this produces the same pictures on any machine, with the real
|
||
/// fonts, without a device attached.
|
||
///
|
||
/// The real fonts are loaded first — without that, Flutter's test harness draws
|
||
/// everything in Ahem's black boxes and the pictures are worthless.
|
||
Future<void> _loadFonts() async {
|
||
for (final (family, asset) in const [
|
||
('Manrope', 'assets/fonts/Manrope-VF.ttf'),
|
||
('GeistMono', 'assets/fonts/GeistMono-VF.ttf'),
|
||
// The icon font too, or every glyph in the picture is a hollow box.
|
||
('packages/lucide_icons_flutter/Lucide',
|
||
'packages/lucide_icons_flutter/assets/lucide.ttf'),
|
||
]) {
|
||
final loader = FontLoader(family)..addFont(rootBundle.load(asset));
|
||
await loader.load();
|
||
}
|
||
}
|
||
|
||
Future<void> _settle(WidgetTester tester, [int ms = 800]) async {
|
||
await tester.pump();
|
||
await tester.pump(Duration(milliseconds: ms));
|
||
await tester.pump(const Duration(milliseconds: 400));
|
||
}
|
||
|
||
Future<void> _shot(WidgetTester tester, String name) async {
|
||
await expectLater(
|
||
find.byType(MaterialApp),
|
||
matchesGoldenFile('snapshots/$name.png'),
|
||
);
|
||
}
|
||
|
||
void main() {
|
||
late DevDoormileApi api;
|
||
|
||
setUp(() {
|
||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||
.setMockMethodCallHandler(
|
||
const MethodChannel('plugins.flutter.io/path_provider'),
|
||
(call) async => '/tmp',
|
||
);
|
||
api = DevDoormileApi();
|
||
DoormileApi.overrideInstance(api);
|
||
LocationService.instance = FixedLocationService();
|
||
});
|
||
|
||
tearDown(() => DoormileApi.overrideInstance(null));
|
||
|
||
testWidgets('screens', (tester) async {
|
||
await _loadFonts();
|
||
|
||
// One phone, portrait: 390x844 at 3x, the frame the design was drawn in.
|
||
tester.view.physicalSize = const Size(1170, 2532);
|
||
tester.view.devicePixelRatio = 3.0;
|
||
addTearDown(tester.view.reset);
|
||
|
||
final state = AppState();
|
||
await tester.pumpWidget(DoormileApp(initialState: state));
|
||
await _settle(tester);
|
||
|
||
// Image.asset resolves off the frame loop, so in a test it never paints
|
||
// unless it is decoded first — without this the wordmark is a blank gap in
|
||
// every picture of the entrance.
|
||
await tester.runAsync(() async {
|
||
await precacheImage(
|
||
const AssetImage('assets/images/doormile-logo.png'),
|
||
tester.element(find.byType(MaterialApp)),
|
||
);
|
||
});
|
||
await _settle(tester);
|
||
await _shot(tester, '01-sign-in');
|
||
|
||
await tester.tap(find.text('Create account'));
|
||
await _settle(tester);
|
||
await _shot(tester, '01b-sign-up');
|
||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||
await _settle(tester);
|
||
|
||
await tester.enterText(find.byType(TextField).first, '9876543210');
|
||
await _settle(tester, 200);
|
||
await tester.tap(find.text('Continue'));
|
||
await _settle(tester);
|
||
await _shot(tester, '02-verify');
|
||
|
||
final boxes = find.byType(TextField);
|
||
for (var i = 0; i < 4; i++) {
|
||
await tester.enterText(boxes.at(i), '1');
|
||
await tester.pump();
|
||
}
|
||
await _settle(tester);
|
||
await _shot(tester, '03-home');
|
||
|
||
await tester.tap(find.text('Orders'));
|
||
await _settle(tester);
|
||
await _shot(tester, '04-orders');
|
||
|
||
await tester.tap(find.text('Account'));
|
||
await _settle(tester);
|
||
await _shot(tester, '05-account');
|
||
|
||
await tester.tap(find.text('Home'));
|
||
await _settle(tester);
|
||
await tester.tap(find.text('Miler assigned'));
|
||
await _settle(tester);
|
||
await _shot(tester, '06-tracking');
|
||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||
await _settle(tester);
|
||
|
||
// Booking flow.
|
||
await tester.tap(find.text('Start booking'));
|
||
await _settle(tester);
|
||
await _shot(tester, '07-pickup-point');
|
||
await tester.tap(find.text('Next'));
|
||
await _settle(tester);
|
||
await tester.tap(find.text('Tamil Nadu'));
|
||
await _settle(tester);
|
||
await tester.tap(find.text('Chennai'));
|
||
await _settle(tester);
|
||
await _shot(tester, '08-destination');
|
||
|
||
await tester.tap(find.text('Next'));
|
||
await _settle(tester);
|
||
await tester.tap(find.text('2:00 – 4:00 PM').first);
|
||
await _settle(tester);
|
||
await _shot(tester, '09-slot');
|
||
|
||
await tester.tap(find.text('Review booking'));
|
||
await _settle(tester);
|
||
await _shot(tester, '10-review');
|
||
|
||
await tester.tap(find.text('Book pickup'));
|
||
await _settle(tester);
|
||
await _shot(tester, '11-booked');
|
||
|
||
// A finished order, for the receipt treatment.
|
||
await tester.tap(find.text('Back to home'));
|
||
await _settle(tester);
|
||
await tester.tap(find.text('Orders'));
|
||
await _settle(tester);
|
||
await tester.tap(find.text('Completed'));
|
||
await _settle(tester);
|
||
await tester.tap(find.text('Salem, Tamil Nadu'));
|
||
await _settle(tester);
|
||
await _shot(tester, '12-receipt');
|
||
|
||
await tester.pump(const Duration(seconds: 3));
|
||
});
|
||
}
|