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>
90 lines
3.0 KiB
Dart
90 lines
3.0 KiB
Dart
import 'package:doormile_cx/data/dev_doormile_api.dart';
|
|
import 'package:doormile_cx/data/models.dart';
|
|
import 'package:doormile_cx/main.dart';
|
|
import 'package:doormile_cx/state/app_state.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
/// The login screen must not walk a customer to the code screen when no code
|
|
/// was sent.
|
|
///
|
|
/// `sent: false` is the server saying it accepted the request and delivered
|
|
/// nothing — the SMS gateway or the mail relay refused it. Advancing anyway
|
|
/// puts four empty boxes in front of someone for a code that does not exist,
|
|
/// and every digit they try is wrong. The failure then reads as "I typed it
|
|
/// wrong" instead of "nothing arrived", which is the wrong problem to hand
|
|
/// somebody.
|
|
///
|
|
/// The resend control on the code screen has always honoured this flag. The
|
|
/// login screen did not, which is the asymmetry these tests pin down.
|
|
|
|
/// The app runs continuous animations, so `pumpAndSettle` never returns.
|
|
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));
|
|
}
|
|
|
|
class _SendFails extends DevDoormileApi {
|
|
@override
|
|
Future<OtpChallenge> sendOtp(String identifier) async =>
|
|
const OtpChallenge(sent: false);
|
|
}
|
|
|
|
class _SendSucceeds extends DevDoormileApi {
|
|
var calls = 0;
|
|
|
|
@override
|
|
Future<OtpChallenge> sendOtp(String identifier) async {
|
|
calls++;
|
|
return const OtpChallenge(sent: true);
|
|
}
|
|
}
|
|
|
|
Future<void> _tapContinue(WidgetTester tester, AppState state) async {
|
|
tester.view.physicalSize = const Size(1230, 9000);
|
|
tester.view.devicePixelRatio = 3.0;
|
|
addTearDown(tester.view.reset);
|
|
|
|
await tester.pumpWidget(DoormileApp(initialState: state));
|
|
await settle(tester);
|
|
|
|
await tester.enterText(find.byType(TextField).first, '9876543210');
|
|
await settle(tester, 200);
|
|
await tester.tap(find.text('Continue'));
|
|
await settle(tester);
|
|
}
|
|
|
|
void main() {
|
|
testWidgets('a code that was never sent does not open the code screen',
|
|
(tester) async {
|
|
await _tapContinue(tester, AppState(api: _SendFails()));
|
|
|
|
expect(
|
|
find.text('Verify your number'),
|
|
findsNothing,
|
|
reason: 'no code was delivered, so there is nothing to verify',
|
|
);
|
|
expect(
|
|
// The entry screen's own headline, which now sits on the brand field
|
|
// rather than as a heading inside the sheet.
|
|
find.text('Book a pickup from your door.'),
|
|
findsOneWidget,
|
|
reason: 'the customer stays where they can try again',
|
|
);
|
|
|
|
// Drain the toast timer so the test ends with nothing pending.
|
|
await tester.pump(const Duration(seconds: 4));
|
|
});
|
|
|
|
testWidgets('a delivered code does open the code screen', (tester) async {
|
|
final api = _SendSucceeds();
|
|
await _tapContinue(tester, AppState(api: api));
|
|
|
|
expect(find.text('Verify your number'), findsOneWidget);
|
|
expect(api.calls, 1);
|
|
|
|
await tester.pump(const Duration(seconds: 4));
|
|
});
|
|
}
|