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 createState() => _AuthEntranceState(); } class _AuthEntranceState extends State with SingleTickerProviderStateMixin { late final AnimationController _c = AnimationController( vsync: this, duration: const Duration(milliseconds: 320), ); late final Animation _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 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( 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( // 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 controllers; final List 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 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, ), ), ); } }