first commit
This commit is contained in:
986
lib/views/onboardscreens/auth_scaffold.dart
Normal file
986
lib/views/onboardscreens/auth_scaffold.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user