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/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 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(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(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(LucideIcons.arrowLeft)); // The glyph is small; the tappable box around it must not be. final target = tester.getRect( find.ancestor( of: find.byIcon(LucideIcons.arrowLeft), 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(LucideIcons.arrowLeft), findsOneWidget); final target = tester.getRect( find.ancestor( of: find.byIcon(LucideIcons.arrowLeft), 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);