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

View File

@@ -0,0 +1,986 @@
import 'package:flutter/material.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.w500,
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(
Icons.arrow_back_rounded,
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(
Icons.arrow_back_rounded,
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 = Icons.arrow_forward_rounded,
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(
Icons.error_outline_rounded,
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,
});
@override
Widget build(BuildContext context) {
final count = controllers.length;
// Taller boxes when there are fewer of them: a 4-digit PIN gets room to
// feel deliberate, a 6-digit code has to stay inside the width.
final ratio = count > 4 ? 1.20 : 1.05;
return Row(
children: [
for (var i = 0; i < count; i++) ...[
if (i > 0) SizedBox(width: MilerCodeBox.gap(context).w),
Expanded(
child: AspectRatio(
aspectRatio: 1 / ratio,
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,
),
),
);
}
}