1033 lines
38 KiB
Dart
1033 lines
38 KiB
Dart
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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|