Files
doormile_milderapp/lib/views/onboardscreens/auth_scaffold.dart
2026-09-09 12:55:23 +05:30

1033 lines
38 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
/// ═════════════════════════════════════════════════════════════════════════
/// ONBOARDING DESIGN SYSTEM
///
/// Sign-in was five screens that each invented their own layout: a 104px logo
/// with a forever-pulsing glow, then a 64px tinted SMS tile, then a 64px tinted
/// lock tile, then a 64px tinted shield tile, then a 120px logo. Same flow,
/// five different first impressions, and the rider could not tell how far
/// through it he was on any of them.
///
/// What Uber, Amazon and Stripe all do with a phone → code → PIN flow is the
/// opposite: **one frame, repeated.** Back arrow, where-am-I, a large question,
/// the single input that answers it, one button. The frame never moves, so the
/// only thing that changes between steps is the question — and that is exactly
/// what should be changing.
///
/// This file is that frame. Three rules it enforces:
///
/// 1. **No decoration above the question.** The tinted icon tiles are gone. An
/// SMS glyph tells the rider nothing that "Enter the 6-digit code" does not,
/// and it pushed the actual content a third of the way down the screen. This
/// is the same call already made on the stop card, where tinted icon tiles
/// lost to plain rows — onboarding was the last place still doing it.
///
/// 2. **Slate, not brown.** The dashboard is slate-on-white
/// ([ColorConstants.slateText] / [ColorConstants.borderStrong]). Onboarding
/// was built on the M3 tonal leftovers of the red brand seed —
/// `onSurfaceVariant` is **#5C403E, a brown**, and `outlineVariant` is
/// **#E5BDBB, a pink**. Every body line and every input border in the flow
/// was one of those two, so the app opened brown-and-pink and then turned
/// slate-and-white the moment the rider signed in.
///
/// 3. **Nothing animates forever.** The old sign-in ran a 3.2s glow +
/// float on `repeat(reverse: true)`, and every intro slide ran its own
/// 1.8s pulse — permanent 60fps repaints on screens whose entire job is to
/// let someone type. Entrances here are one-shot and short; see
/// [AuthEntrance].
/// ═════════════════════════════════════════════════════════════════════════
/// Shared type scale. Every heading in the flow is the same size and weight,
/// which is what makes the five screens read as one thing.
abstract final class AuthType {
static TextStyle get title => TextStyle(
fontSize: 27.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.7,
height: 1.15,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
);
static TextStyle get subtitle => TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w600,
height: 1.45,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
);
static TextStyle get fieldLabel => TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
);
static TextStyle get meta => TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w600,
height: 1.45,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
);
}
/// One-shot fade + rise for a screen's content.
///
/// Replaces sign-in's 1100ms per-element cascade, which staggered eight items
/// at 80ms apart and so took over a second to finish — on a screen where the
/// keyboard is already up and the rider is trying to type. A single short move
/// on the whole block reads as "the screen arrived" without making anyone wait
/// for it, and it cannot be left running.
class AuthEntrance extends StatefulWidget {
final Widget child;
const AuthEntrance({super.key, required this.child});
@override
State<AuthEntrance> createState() => _AuthEntranceState();
}
class _AuthEntranceState extends State<AuthEntrance>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 320),
);
late final Animation<double> _a = CurvedAnimation(
parent: _c,
curve: Curves.easeOutCubic,
);
@override
void initState() {
super.initState();
_c.forward();
}
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _a,
builder: (_, child) => Opacity(
opacity: _a.value,
child: Transform.translate(
offset: Offset(0, 12 * (1 - _a.value)),
child: child,
),
),
child: widget.child,
);
}
}
/// ── Where am I in this flow ──
///
/// The flow is phone → code → PIN, and none of the three screens said so. A
/// rider who has just typed a code has no way to know whether he is nearly done
/// or has five screens to go, and "how much longer is this" is the single
/// biggest reason people abandon a sign-up half way.
///
/// The count starts at the *code* screen, not the number screen. Typing a
/// phone number is the door to two different journeys — a returning rider goes
/// straight to an MPIN screen that has no counter and never needed one — so
/// counting from there promised a three-step form to people who had two
/// screens left, and to others who had one. What the rail measures now is the
/// part that is genuinely a multi-screen sign-up.
///
/// Deliberately a bar and a count rather than dots: dots say "there are three
/// of these" but not which are behind you, and this reuses the segmented-bar
/// language already used for route progress on Bookings.
class AuthSteps extends StatelessWidget {
final int step;
final int total;
const AuthSteps({super.key, required this.step, this.total = 3});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'STEP $step OF $total',
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.0,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 7.h),
Row(
children: [
for (var i = 1; i <= total; i++) ...[
if (i > 1) SizedBox(width: 5.w),
Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOut,
height: 4.h,
decoration: BoxDecoration(
color: i <= step
? ColorConstants.primary
: ColorConstants.borderStrong,
borderRadius: BorderRadius.circular(
DesignConstants.radiusFull,
),
),
),
),
],
],
),
],
);
}
}
/// The one frame every auth screen wears.
///
/// Content is **top-anchored, not centred**: the question and its input sit
/// high, where they stay put when the keyboard opens, instead of being shoved
/// around by it. The CTA is pinned to the bottom of the remaining space, so it
/// rides just above the keyboard rather than hiding behind it.
class AuthScaffold extends StatelessWidget {
/// Back affordance. Null hides it — used on the first screen of the flow and
/// on any screen there is no going back from.
final VoidCallback? onBack;
/// 1-based position in the sign-up flow. Null on screens outside it — the
/// returning-user unlock is not "step 1 of 2" of anything, and neither is
/// the number screen that leads to it.
final int? step;
final int totalSteps;
final String title;
final String? subtitle;
/// Everything under the title block.
final List<Widget> children;
/// Pinned to the bottom, above the keyboard. Usually an [AuthPrimaryButton]
/// plus whatever legal text belongs with it.
final Widget footer;
/// Optional trailing widget on the top bar (e.g. a brand mark).
final Widget? topRight;
/// A full-bleed brand block above the content, which then sits on a rounded
/// sheet lifted over it.
///
/// Null on every screen but the first, and deliberately so — see
/// [AuthBrandBanner]. When null this widget renders exactly the layout it
/// always has, so opting one screen in cannot move the other three.
final Widget? banner;
const AuthScaffold({
super.key,
this.onBack,
this.step,
this.totalSteps = 3,
required this.title,
this.subtitle,
required this.children,
required this.footer,
this.topRight,
this.banner,
});
@override
Widget build(BuildContext context) {
if (banner != null) return _banneredLayout(context);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: ColorConstants.surface,
body: SafeArea(
child: Column(
children: [
// ── Top bar ──
// A 44dp back target, not a bare 20sp glyph in an IconButton with
// 8px of padding. This is the control a rider hits with a glove on.
if (onBack != null || topRight != null)
Padding(
padding: EdgeInsets.fromLTRB(10.w, 6.h, 16.w, 0),
child: Row(
children: [
if (onBack != null)
_TapTarget(
onTap: onBack!,
semanticLabel: 'Back',
child: Icon(
LucideIcons.arrowLeft,
size: 22.sp,
color: ColorConstants.slateText,
),
)
else
SizedBox(width: 14.w),
const Spacer(),
if (topRight != null) topRight!,
],
),
),
Expanded(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.fromLTRB(24.w, 0, 24.w, 8.h),
child: AuthEntrance(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 14.h),
if (step != null) ...[
AuthSteps(step: step!, total: totalSteps),
SizedBox(height: 26.h),
],
Text(title, style: AuthType.title),
if (subtitle != null) ...[
SizedBox(height: 9.h),
Text(subtitle!, style: AuthType.subtitle),
],
SizedBox(height: 30.h),
...children,
],
),
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(24.w, 4.h, 24.w, 10.h),
child: footer,
),
],
),
),
),
);
}
/// ── Banner + sheet ──
///
/// The brand block runs full-bleed to the top of the screen, status bar
/// included, and the content rides on a rounded sheet lifted [_lift] over it
/// so the two read as one card sitting on a coloured ground rather than as
/// two stacked bands.
///
/// The lift is a `Transform.translate` rather than a `Stack`, so there is
/// exactly one animated height on the screen — the banner's. A Stack would
/// need the banner and the content's top offset to animate in lockstep, and
/// two controllers describing one movement is how they end up disagreeing.
/// The sheet finishes [_lift] short of the bottom as a result, which is
/// invisible: the Scaffold behind it is the same colour. The footer's bottom
/// padding carries that back so the button does not ride high.
Widget _banneredLayout(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
// Light glyphs: the status bar sits on the brand block, not on the page.
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: ColorConstants.pureSurface,
body: Column(
children: [
// ── The banner has to be told about the keyboard ──
//
// `Scaffold` with `resizeToAvoidBottomInset` (the default, and what
// lifts the footer clear of the keyboard) *consumes* the bottom
// view inset: everything inside `body` sees `viewInsets.bottom == 0`
// whether the keyboard is up or not. So a child that wants to react
// to the keyboard cannot ask the obvious question from in there.
//
// This context is AuthScaffold's own, above the Scaffold, so it
// still carries the real value. Re-providing it to the banner's
// subtree hands the untouched insets back to exactly the one widget
// that needs them, and leaves the Scaffold's own resizing alone.
Stack(
children: [
MediaQuery(data: MediaQuery.of(context), child: banner!),
// ── Back rides on the banner ──
//
// The plain layout gives back its own row above the content.
// There is no such row here, and three of the four screens in
// this flow pass an `onBack` — so without this the banner would
// silently swallow the only way out of OTP, Create MPIN and
// Unlock. White, because it sits on the brand block.
if (onBack != null)
PositionedDirectional(
start: 4.w,
top: MediaQuery.paddingOf(context).top + 2.h,
child: _TapTarget(
onTap: onBack!,
semanticLabel: 'Back',
child: Icon(
LucideIcons.arrowLeft,
size: 22.sp,
color: ColorConstants.onAccent,
),
),
),
],
),
Expanded(
child: Transform.translate(
offset: Offset(0, -_lift.h),
child: Container(
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.vertical(
top: Radius.circular(28.r),
),
),
child: SafeArea(
top: false,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.fromLTRB(24.w, 0, 24.w, 8.h),
child: AuthEntrance(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 26.h),
if (step != null) ...[
AuthSteps(step: step!, total: totalSteps),
SizedBox(height: 24.h),
],
Text(title, style: AuthType.title),
if (subtitle != null) ...[
SizedBox(height: 9.h),
Text(subtitle!, style: AuthType.subtitle),
],
SizedBox(height: 28.h),
...children,
],
),
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(
24.w,
4.h,
24.w,
10.h + _lift.h,
),
child: footer,
),
],
),
),
),
),
),
],
),
),
);
}
/// How far the sheet is pulled up over the banner.
static const double _lift = 22;
}
/// ─────────────────────────────────────────────────────────────────────────
/// THE BRAND BLOCK
///
/// The masthead every screen in the onboarding flow wears. One block across
/// Sign In, OTP, Create MPIN and Unlock, so the four screens read as one
/// doorway rather than as four forms that happen to follow each other.
///
/// ── The wordmark is knocked out, not re-exported ──
///
/// `doormile-logo.png` is RGBA, so `BlendMode.srcIn` fills every
/// non-transparent pixel white and gives a clean reverse of the red original.
/// That is deliberately preferred to shipping a second white asset: two logo
/// files drift, and this one cannot.
///
/// ── Full block, or strip ──
///
/// The rule is what the screen does on arrival, and there are only two cases:
///
/// • **Opens with the keyboard down** — Sign In, the one screen with nothing
/// focused. It gets the full 208dp block, and gives half of it back the
/// moment the rider taps the field. The brand moment is worth the height
/// while he is deciding to start, and worth nothing while he is typing.
///
/// • **Opens with the keyboard already up** — OTP, Create MPIN, Unlock, all of
/// which focus their first box in a post-frame callback. These pass
/// [compact] and get the strip immediately, with no animation at all.
/// Adaptive sizing here would render the full block for one frame and then
/// shrink it, so every launch would open by animating the masthead away —
/// which is the splash screen this app deleted, wearing a different hat.
///
/// Unlock is the screen that makes the second case matter: the rider opens it
/// every morning, and a daily animation is a daily tax.
/// ─────────────────────────────────────────────────────────────────────────
class AuthBrandBanner extends StatelessWidget {
/// Forces the strip. See the class note — this is for screens that raise the
/// keyboard themselves on the first frame.
final bool compact;
const AuthBrandBanner({super.key, this.compact = false});
static const double _restHeight = 208;
static const double _typingHeight = 128;
/// Source wordmark is 748×100, and the ratio is held explicitly so the mark
/// cannot be squashed by a layout change.
static const double _markAspect = 748 / 100;
@override
Widget build(BuildContext context) {
final typing = compact || MediaQuery.viewInsetsOf(context).bottom > 0;
final markWidth = (typing ? 150.0 : 196.0).w;
return AnimatedContainer(
duration: DesignConstants.motionState,
curve: Curves.easeOutCubic,
width: double.infinity,
height:
(typing ? _typingHeight : _restHeight).h +
MediaQuery.paddingOf(context).top,
color: ColorConstants.primary,
child: ClipRect(
child: Stack(
children: [
// ── The rings are the logo's own motif ──
//
// "mile" is drawn as concentric arcs in the Doormile mark, so the
// block is decorated with the same shape rather than with imported
// ornament. Bled off both edges and held at 7% white: at that
// weight they read as texture on the brand colour and never as
// objects the rider might try to tap.
Positioned(
left: -78.w,
top: -34.h,
child: const _BrandRing(size: 196),
),
Positioned(
right: -92.w,
bottom: -78.h,
child: const _BrandRing(size: 244),
),
Center(
child: Padding(
padding: EdgeInsets.only(
top: MediaQuery.paddingOf(context).top * 0.5,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Semantics(
label: 'Doormile',
image: true,
child: SizedBox(
width: markWidth,
height: markWidth / _markAspect,
child: ColorFiltered(
colorFilter: const ColorFilter.mode(
Colors.white,
BlendMode.srcIn,
),
child: Image.asset(
'assets/images/doormile-logo.png',
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
),
),
),
),
SizedBox(height: 12.h),
// Names the product under the company that makes it. The
// rider was handed "the Miler app" by his hub; the mark
// above says Doormile. This is the line that ties the two
// together, and it is the reason it is words and not a
// decorative flourish.
Text(
'MILER · RIDER APP',
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.6,
color: ColorConstants.onAccent.withValues(alpha: 0.72),
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
],
),
),
);
}
}
/// One faint concentric ring from the wordmark's arc motif.
class _BrandRing extends StatelessWidget {
final double size;
const _BrandRing({required this.size});
@override
Widget build(BuildContext context) {
return SizedBox(
width: size.w,
height: size.w,
child: CustomPaint(painter: _RingPainter()),
);
}
}
class _RingPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final centre = size.center(Offset.zero);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = size.width * 0.055
..color = Colors.white.withValues(alpha: 0.07);
// Three rings, stepping inward by the stroke plus its own gap — the same
// rhythm the "m" and "i" of the wordmark are built from.
for (var i = 0; i < 3; i++) {
canvas.drawCircle(centre, size.width * (0.46 - i * 0.13), paint);
}
}
@override
bool shouldRepaint(covariant _RingPainter oldDelegate) => false;
}
/// 44dp-minimum tap target around a small glyph.
class _TapTarget extends StatelessWidget {
final VoidCallback onTap;
final Widget child;
final String semanticLabel;
const _TapTarget({
required this.onTap,
required this.child,
required this.semanticLabel,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: semanticLabel,
child: InkResponse(
onTap: onTap,
radius: 24.r,
child: SizedBox(
width: ButtonSizes.minTapTarget,
height: ButtonSizes.minTapTarget,
child: Center(child: child),
),
),
);
}
}
/// The single action a screen exists for.
///
/// Was copy-pasted three times, identically, with a `Row` of label + arrow
/// inside an `ElevatedButton.styleFrom` that overrode colour and shape only.
/// That last detail matters: the app theme styles `ElevatedButton` for
/// full-width form buttons with `vertical: 18` padding and a 56dp
/// `minimumSize`, and `styleFrom` keeps whatever you do not replace — so the
/// button only ever fitted because 56 happened to be the exact height these
/// screens asked for. The overrides below make the height this widget's own
/// business, so it cannot be broken by changing a number.
class AuthPrimaryButton extends StatelessWidget {
final String label;
final IconData? icon;
final bool loading;
final VoidCallback? onPressed;
const AuthPrimaryButton({
super.key,
required this.label,
this.icon = LucideIcons.arrowRight,
this.loading = false,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
final enabled = onPressed != null && !loading;
return SizedBox(
width: double.infinity,
height: ButtonSizes.primary,
child: ElevatedButton(
onPressed: enabled ? onPressed : null,
style: ElevatedButton.styleFrom(
backgroundColor: ColorConstants.primary,
foregroundColor: Colors.white,
// A disabled CTA reads as "not yet", so it stays recognisably the
// same button rather than turning grey and looking broken.
disabledBackgroundColor: ColorConstants.primary.withValues(
alpha: 0.30,
),
disabledForegroundColor: Colors.white.withValues(alpha: 0.85),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ButtonSizes.radius.r),
),
padding: EdgeInsets.symmetric(horizontal: 16.w),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: loading
? SizedBox(
height: 22.sp,
width: 22.sp,
child: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
fontFamily: FontConstants.fontFamily,
),
),
),
if (icon != null) ...[
SizedBox(width: 8.w),
Icon(icon, color: Colors.white, size: 19.sp),
],
],
),
),
);
}
}
/// Quiet text action — Resend, Forgot MPIN, Change number.
class AuthTextAction extends StatelessWidget {
final String label;
final IconData? icon;
final VoidCallback? onPressed;
const AuthTextAction({
super.key,
required this.label,
this.icon,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
final on = onPressed != null;
final color = on ? ColorConstants.primary : ColorConstants.secondaryText;
return TextButton(
onPressed: onPressed,
style: TextButton.styleFrom(
minimumSize: Size(0, ButtonSizes.minTapTarget),
padding: EdgeInsets.symmetric(horizontal: 12.w),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 15.sp, color: color),
SizedBox(width: 6.w),
],
Text(
label,
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w800,
color: color,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
}
/// ── Inline error ──
///
/// The flow used to report a wrong or expired code with a floating `SnackBar`
/// at the bottom of the screen — under the keyboard, on the one screen
/// guaranteed to have the keyboard up. The rider saw the boxes clear themselves
/// and nothing else, which reads as a crash rather than a rejection.
///
/// An error belongs next to the thing that caused it, and the input it refers to
/// turns red at the same time (see [AuthCodeRow.hasError]).
class AuthInlineError extends StatelessWidget {
final String? message;
const AuthInlineError({super.key, this.message});
@override
Widget build(BuildContext context) {
return AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
alignment: Alignment.topLeft,
child: message == null
? const SizedBox(width: double.infinity)
: Padding(
padding: EdgeInsets.only(top: 10.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
LucideIcons.circleAlert,
size: 15.sp,
color: ColorConstants.errorRed,
),
SizedBox(width: 6.w),
Expanded(
child: Text(
message!,
style: TextStyle(
color: ColorConstants.errorRed,
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
height: 1.35,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
);
}
}
/// ── One row of single-character boxes ──
///
/// Shared by the 6-digit OTP screen and both 4-digit MPIN screens, which
/// previously drew this control three times in three different ways: 46×56 at
/// radius 12 with a pink border; 70×70 at radius 16 filled grey with a
/// transparent border; 70×70 at radius 14 white with a pink border. Same
/// control, same flow, three looks.
///
/// The boxes **flex** rather than carrying fixed widths. The old versions hard-
/// coded `46.w` / `70.w` inside a `spaceBetween` row, which only fits because
/// the design width happens to divide that way — four 70s plus gaps is 316 of a
/// 342 usable width, so a slightly wider gutter or a fifth box overflows. Sizing
/// from the space available cannot overflow at any count or screen width.
class AuthCodeRow extends StatelessWidget {
final List<TextEditingController> controllers;
final List<FocusNode> nodes;
final bool obscure;
final bool hasError;
final void Function(String value, int index) onChanged;
const AuthCodeRow({
super.key,
required this.controllers,
required this.nodes,
required this.onChanged,
this.obscure = false,
this.hasError = false,
});
/// ── The boxes stopped stretching ──
///
/// Each box was `Expanded` inside an `AspectRatio`, so four of them divided
/// the *whole* content width between themselves: on a 390pt phone that is a
/// **79×83pt** box, four of them, filling the screen edge to edge. A PIN
/// field that size reads as a keypad rather than as an input — the digits
/// float in the middle of a large empty square, the group dominates a screen
/// whose only job is four taps, and it gets worse on a tablet, where the
/// boxes keep growing with the window.
///
/// Every app that does this well — the ones a rider already uses to bank and
/// to sign in — sizes the box to the *digit*, not to the screen, and centres
/// the group. 56pt is a comfortable target well above the 44pt floor, and
/// four of them plus their gaps come to 260 of the 390 available, so the
/// group reads as one control with air around it instead of a wall.
///
/// The size is a *preference*, not a constant: [LayoutBuilder] caps it at
/// whatever the width actually allows, so a 6-digit code on a 320pt phone
/// shrinks to fit rather than overflowing. It can get smaller than preferred;
/// it can never get bigger.
@override
Widget build(BuildContext context) {
final count = controllers.length;
final double gap = MilerCodeBox.gap(context).w * (count > 4 ? 1 : 1.5);
// A 4-digit PIN gets the full target; a 6-digit code starts smaller so it
// is not relying on the cap to be legible on a narrow phone.
final double preferred = (count > 4 ? 46 : 56).w;
return LayoutBuilder(
builder: (context, constraints) {
final double available = constraints.maxWidth - gap * (count - 1);
final double box = preferred < available / count
? preferred
: available / count;
// Barely taller than it is wide. A tall box puts the digit in the
// middle of empty space; this reads as a field.
final double height = box * 1.12;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < count; i++) ...[
if (i > 0) SizedBox(width: gap),
SizedBox(
width: box,
height: height,
child: _Box(
controller: controllers[i],
node: nodes[i],
obscure: obscure,
hasError: hasError,
onChanged: (v) => onChanged(v, i),
),
),
],
],
);
},
);
}
}
class _Box extends StatefulWidget {
final TextEditingController controller;
final FocusNode node;
final bool obscure;
final bool hasError;
final ValueChanged<String> onChanged;
const _Box({
required this.controller,
required this.node,
required this.obscure,
required this.hasError,
required this.onChanged,
});
@override
State<_Box> createState() => _BoxState();
}
class _BoxState extends State<_Box> {
@override
void initState() {
super.initState();
// Repaints only this box on focus, rather than the whole screen. The old
// screens called setState on every focus change and every keystroke to
// restyle six boxes, rebuilding the entire page each time.
widget.node.addListener(_onFocus);
}
@override
void dispose() {
widget.node.removeListener(_onFocus);
super.dispose();
}
void _onFocus() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final focused = widget.node.hasFocus;
final filled = widget.controller.text.isNotEmpty;
// The shared spec — see [MilerCodeBox]. The login code and the delivery OTP
// are two input models (per-box controllers here, one hidden field there)
// but the rider must meet one control.
return AnimatedContainer(
duration: DesignConstants.motionState,
decoration: MilerCodeBox.decoration(
focused: focused,
filled: filled,
hasError: widget.hasError,
accent: ColorConstants.primary,
),
child: Center(
child: TextField(
controller: widget.controller,
focusNode: widget.node,
obscureText: widget.obscure,
obscuringCharacter: '•',
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(1),
],
style: MilerCodeBox.digit(
ColorConstants.primary,
hasError: widget.hasError,
),
cursorColor: ColorConstants.primary,
cursorWidth: 2,
decoration: const InputDecoration(
counterText: '',
filled: false,
isDense: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
),
onChanged: widget.onChanged,
),
),
);
}
}