354 lines
13 KiB
Dart
354 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
|
||
import 'package:miler/views/helpers/constants/app_theme.dart';
|
||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
|
||
|
||
/// The onboarding flow's shared frame. These pin the two things that were
|
||
/// actually broken in the five hand-rolled versions it replaced: code boxes
|
||
/// sized in fixed `.w` units inside a `spaceBetween` row, and a CTA inheriting
|
||
/// the app theme's full-width-form metrics.
|
||
void main() {
|
||
Future<void> pump(
|
||
WidgetTester tester,
|
||
Widget child, {
|
||
Size screen = const Size(390, 844),
|
||
}) async {
|
||
tester.view.physicalSize = Size(screen.width * 3, screen.height * 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(
|
||
theme: AppTheme.light,
|
||
home: child,
|
||
),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
}
|
||
|
||
Widget codeRow(int count, {bool error = false}) {
|
||
final controllers =
|
||
List.generate(count, (_) => TextEditingController());
|
||
final nodes = List.generate(count, (_) => FocusNode());
|
||
return Scaffold(
|
||
body: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||
child: AuthCodeRow(
|
||
controllers: controllers,
|
||
nodes: nodes,
|
||
hasError: error,
|
||
onChanged: (_, _) {},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
group('AuthCodeRow', () {
|
||
// The old OTP row hard-coded 46.w boxes and the MPIN rows hard-coded 70.w,
|
||
// inside `MainAxisAlignment.spaceBetween`. That only fits because the design
|
||
// width happens to divide that way; it has no headroom and cannot adapt.
|
||
for (final count in [4, 6]) {
|
||
for (final width in [320.0, 360.0, 390.0, 430.0]) {
|
||
testWidgets('$count boxes fit a ${width.toInt()}pt screen',
|
||
(tester) async {
|
||
await pump(tester, codeRow(count), screen: Size(width, 780));
|
||
expect(tester.takeException(), isNull);
|
||
expect(find.byType(TextField), findsNCount(count));
|
||
|
||
// Every box inside the 24pt gutters, none overlapping.
|
||
final boxes = tester
|
||
.widgetList<TextField>(find.byType(TextField))
|
||
.toList();
|
||
expect(boxes.length, count);
|
||
|
||
final rects = find.byType(TextField).evaluate().map((e) {
|
||
final ro = e.renderObject! as RenderBox;
|
||
final pos = ro.localToGlobal(Offset.zero);
|
||
return Rect.fromLTWH(pos.dx, pos.dy, ro.size.width, ro.size.height);
|
||
}).toList();
|
||
|
||
for (final r in rects) {
|
||
expect(r.left, greaterThanOrEqualTo(-0.01));
|
||
expect(r.right, lessThanOrEqualTo(width + 0.01));
|
||
expect(r.width, greaterThan(0));
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
testWidgets('an error turns the boxes red', (tester) async {
|
||
await pump(tester, codeRow(6, error: true));
|
||
expect(tester.takeException(), isNull);
|
||
});
|
||
});
|
||
|
||
group('AuthPrimaryButton', () {
|
||
testWidgets('the label is not squeezed by the theme form metrics',
|
||
(tester) async {
|
||
await pump(
|
||
tester,
|
||
const Scaffold(
|
||
body: Padding(
|
||
padding: EdgeInsets.all(24),
|
||
child: AuthPrimaryButton(label: 'Verify & Continue', onPressed: _noop),
|
||
),
|
||
),
|
||
);
|
||
expect(tester.takeException(), isNull);
|
||
|
||
final button = tester.getRect(find.byType(ElevatedButton));
|
||
final icon = tester.getRect(find.byType(Icon));
|
||
final text = tester.getRect(find.text('Verify & Continue'));
|
||
|
||
// The app theme's `vertical: 18` padding would leave a 20pt content band
|
||
// in a 56pt button and crush both of these; see AuthPrimaryButton's note.
|
||
expect(icon.height, greaterThan(14));
|
||
expect(text.height, greaterThan(12));
|
||
for (final part in [icon, text]) {
|
||
expect(part.top, greaterThanOrEqualTo(button.top - 0.01));
|
||
expect(part.bottom, lessThanOrEqualTo(button.bottom + 0.01));
|
||
}
|
||
});
|
||
|
||
testWidgets('a null callback disables it', (tester) async {
|
||
await pump(
|
||
tester,
|
||
const Scaffold(
|
||
body: Padding(
|
||
padding: EdgeInsets.all(24),
|
||
child: AuthPrimaryButton(label: 'Continue', onPressed: null),
|
||
),
|
||
),
|
||
);
|
||
final b = tester.widget<ElevatedButton>(find.byType(ElevatedButton));
|
||
expect(b.onPressed, isNull);
|
||
});
|
||
});
|
||
|
||
group('AuthScaffold', () {
|
||
testWidgets('pins the footer below the scrolling body', (tester) async {
|
||
await pump(
|
||
tester,
|
||
AuthScaffold(
|
||
onBack: _noop,
|
||
step: 2,
|
||
title: 'Enter the 6-digit code',
|
||
subtitle: 'Sent by SMS.',
|
||
footer: const AuthPrimaryButton(label: 'Verify', onPressed: _noop),
|
||
children: [Container(height: 200, color: Colors.white)],
|
||
),
|
||
);
|
||
expect(tester.takeException(), isNull);
|
||
|
||
expect(find.text('STEP 2 OF 3'), findsOneWidget);
|
||
expect(find.text('Enter the 6-digit code'), findsOneWidget);
|
||
|
||
final body = tester.getRect(find.byType(SingleChildScrollView));
|
||
final footer = tester.getRect(find.byType(ElevatedButton));
|
||
expect(footer.top, greaterThanOrEqualTo(body.bottom - 0.01),
|
||
reason: 'the CTA sits below the scroll area, not inside it');
|
||
});
|
||
|
||
testWidgets('the back target clears the 44dp floor', (tester) async {
|
||
await pump(
|
||
tester,
|
||
AuthScaffold(
|
||
onBack: _noop,
|
||
title: 'T',
|
||
footer: const AuthPrimaryButton(label: 'Go', onPressed: _noop),
|
||
children: const [],
|
||
),
|
||
);
|
||
final back = tester.getRect(find.byIcon(Icons.arrow_back_rounded));
|
||
// The glyph is small; the tappable box around it must not be.
|
||
final target = tester.getRect(find.ancestor(
|
||
of: find.byIcon(Icons.arrow_back_rounded),
|
||
matching: find.byType(InkResponse),
|
||
));
|
||
expect(back.width, lessThan(target.width));
|
||
expect(target.width, greaterThanOrEqualTo(ButtonSizes.minTapTarget));
|
||
expect(target.height, greaterThanOrEqualTo(ButtonSizes.minTapTarget));
|
||
});
|
||
|
||
testWidgets('no step rail when the screen is outside the flow',
|
||
(tester) async {
|
||
await pump(
|
||
tester,
|
||
AuthScaffold(
|
||
title: 'Enter your MPIN',
|
||
footer: const AuthPrimaryButton(label: 'Unlock', onPressed: _noop),
|
||
children: const [],
|
||
),
|
||
);
|
||
expect(find.textContaining('STEP'), findsNothing);
|
||
});
|
||
|
||
// ── The banner is opt-in ──
|
||
//
|
||
// Only Sign In passes one. The other three screens in the flow must keep
|
||
// the plain top-anchored frame, because a brand block there would spend
|
||
// keyboard room on a screen whose whole job is to accept six digits.
|
||
testWidgets('no banner unless a screen asks for one', (tester) async {
|
||
await pump(
|
||
tester,
|
||
AuthScaffold(
|
||
title: 'Enter the 6-digit code',
|
||
footer: const AuthPrimaryButton(label: 'Verify', onPressed: _noop),
|
||
children: const [],
|
||
),
|
||
);
|
||
expect(find.byType(AuthBrandBanner), findsNothing);
|
||
});
|
||
});
|
||
|
||
/// ── The brand block on Sign In ──
|
||
///
|
||
/// The two things that can actually break here: the block eating the field on
|
||
/// a short screen with the keyboard up, and the wordmark being squashed by a
|
||
/// layout change. Both are asserted rather than eyeballed.
|
||
group('AuthBrandBanner', () {
|
||
Widget bannered() => AuthScaffold(
|
||
step: 1,
|
||
banner: const AuthBrandBanner(),
|
||
title: 'Enter your number',
|
||
subtitle: 'We use it to sign you in.',
|
||
footer: const AuthPrimaryButton(label: 'Continue', onPressed: _noop),
|
||
children: [Container(height: 56, color: Colors.white)],
|
||
);
|
||
|
||
for (final width in [320.0, 360.0, 390.0, 430.0]) {
|
||
testWidgets('lays out on a ${width.toInt()}pt screen', (tester) async {
|
||
await pump(tester, bannered(), screen: Size(width, 720));
|
||
expect(tester.takeException(), isNull);
|
||
expect(find.byType(AuthBrandBanner), findsOneWidget);
|
||
expect(find.text('MILER · RIDER APP'), findsOneWidget);
|
||
|
||
// The block must not run away with the screen. Half the height is the
|
||
// point at which the sheet stops being the subject of the page.
|
||
final banner = tester.getRect(find.byType(AuthBrandBanner));
|
||
expect(banner.height, lessThan(720 * 0.5),
|
||
reason: 'the brand block is a header, not the page');
|
||
});
|
||
}
|
||
|
||
testWidgets('the wordmark keeps its aspect ratio', (tester) async {
|
||
await pump(tester, bannered());
|
||
final mark = tester.getRect(find.image(
|
||
const AssetImage('assets/images/doormile-logo.png'),
|
||
));
|
||
// Source is 748×100. A squashed mark is the classic symptom of a banner
|
||
// being re-laid-out without the ratio being held explicitly.
|
||
expect(mark.width / mark.height, closeTo(748 / 100, 0.05));
|
||
});
|
||
|
||
testWidgets('it gets out of the way when the keyboard opens',
|
||
(tester) async {
|
||
await pump(tester, bannered());
|
||
final atRest = tester.getRect(find.byType(AuthBrandBanner)).height;
|
||
|
||
// A keyboard is a bottom view inset; that is the only signal the widget
|
||
// reads, so it is the only one the test needs to fake.
|
||
//
|
||
// This is also the regression guard for a real bug: `Scaffold` with
|
||
// `resizeToAvoidBottomInset` strips the bottom inset from everything in
|
||
// its `body`, so the banner saw zero however high the keyboard was. It
|
||
// only works because AuthScaffold re-provides its own MediaQuery.
|
||
tester.view.viewInsets = FakeViewPadding(bottom: 300 * 3);
|
||
addTearDown(tester.view.resetViewInsets);
|
||
await tester.pumpAndSettle();
|
||
|
||
final typing = tester.getRect(find.byType(AuthBrandBanner)).height;
|
||
expect(typing, lessThan(atRest),
|
||
reason: 'the block halves itself so the field stays visible');
|
||
// And the mark is still there — it shrinks, it does not disappear.
|
||
expect(find.byType(AuthBrandBanner), findsOneWidget);
|
||
});
|
||
|
||
// The screens that focus a box on the first frame must not render the full
|
||
// block and then animate it away — that is the deleted splash, rebuilt.
|
||
testWidgets('compact starts small and stays small', (tester) async {
|
||
await pump(
|
||
tester,
|
||
AuthScaffold(
|
||
banner: const AuthBrandBanner(compact: true),
|
||
title: 'Enter your MPIN',
|
||
footer: const AuthPrimaryButton(label: 'Unlock', onPressed: _noop),
|
||
children: const [],
|
||
),
|
||
);
|
||
final first = tester.getRect(find.byType(AuthBrandBanner)).height;
|
||
|
||
tester.view.viewInsets = FakeViewPadding(bottom: 300 * 3);
|
||
addTearDown(tester.view.resetViewInsets);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(tester.getRect(find.byType(AuthBrandBanner)).height, first,
|
||
reason: 'a compact banner has nowhere to shrink to');
|
||
});
|
||
|
||
// ── The banner used to eat the back button ──
|
||
//
|
||
// The plain layout renders `onBack` in its own row above the content. The
|
||
// bannered one has no such row, so a screen passing `onBack` lost its only
|
||
// way out — which is three of the four screens in this flow.
|
||
testWidgets('back survives the banner', (tester) async {
|
||
await pump(
|
||
tester,
|
||
AuthScaffold(
|
||
onBack: _noop,
|
||
banner: const AuthBrandBanner(compact: true),
|
||
title: 'Enter the 6-digit code',
|
||
footer: const AuthPrimaryButton(label: 'Verify', onPressed: _noop),
|
||
children: const [],
|
||
),
|
||
);
|
||
expect(find.byIcon(Icons.arrow_back_rounded), findsOneWidget);
|
||
|
||
final target = tester.getRect(find.ancestor(
|
||
of: find.byIcon(Icons.arrow_back_rounded),
|
||
matching: find.byType(InkResponse),
|
||
));
|
||
expect(target.width, greaterThanOrEqualTo(ButtonSizes.minTapTarget));
|
||
expect(target.height, greaterThanOrEqualTo(ButtonSizes.minTapTarget));
|
||
|
||
// On the banner, not below it.
|
||
final banner = tester.getRect(find.byType(AuthBrandBanner));
|
||
expect(target.center.dy, lessThan(banner.bottom));
|
||
});
|
||
});
|
||
|
||
group('AuthInlineError', () {
|
||
testWidgets('takes no space when there is nothing to say', (tester) async {
|
||
await pump(
|
||
tester,
|
||
const Scaffold(body: Center(child: AuthInlineError())),
|
||
);
|
||
expect(tester.getSize(find.byType(AuthInlineError)).height, 0);
|
||
});
|
||
|
||
testWidgets('shows the message when there is', (tester) async {
|
||
await pump(
|
||
tester,
|
||
const Scaffold(
|
||
body: Center(child: AuthInlineError(message: 'That code is wrong.')),
|
||
),
|
||
);
|
||
expect(find.text('That code is wrong.'), findsOneWidget);
|
||
expect(tester.getSize(find.byType(AuthInlineError)).height,
|
||
greaterThan(0));
|
||
});
|
||
});
|
||
}
|
||
|
||
void _noop() {}
|
||
|
||
/// `findsNWidgets` reads badly inline at these call sites.
|
||
Matcher findsNCount(int n) => findsNWidgets(n);
|