first commit

This commit is contained in:
2026-08-11 13:16:33 +05:30
parent 18531bf46b
commit c8350563d9
388 changed files with 69656 additions and 0 deletions

353
test/auth_design_test.dart Normal file
View File

@@ -0,0 +1,353 @@
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);