first commit

This commit is contained in:
2026-08-11 13:16:33 +05:30
parent 18531bf46b
commit c8350563d9
388 changed files with 69656 additions and 0 deletions

View File

@@ -0,0 +1,260 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:miler/views/helpers/widgets/page_transitions.dart';
import 'package:miler/controllers/auth.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/onboardscreens/Mpin.dart';
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// CREATE MPIN — step 3 of 3.
///
/// ── What changed and why ──
///
/// **A mismatch now says so.** Previously the CTA was simply disabled while
/// `isMpinMatched` was false, and nothing on screen explained why: the rider
/// entered four digits, entered four more, and the button stayed dead. Two
/// non-matching 4-digit PINs look identical when both are rendered as `••••`, so
/// there was no way to find the mistake by looking. The confirm row now turns red
/// and prints the reason, and it clears itself so he can retype rather than
/// hunting for which box is wrong.
///
/// **Weak PINs are refused.** `1111` and `1234` were accepted, on a credential
/// that unlocks a rider's account and their COD cash reconciliation. Both are in
/// every attacker's first ten guesses — and `1234` is this app's own documented
/// master PIN, so a rider could set a PIN that collides with it.
///
/// **The decorative shield tile is gone**, and the two 4-digit rows are drawn by
/// the shared [AuthCodeRow] instead of a private copy — this screen and the
/// unlock screen were rendering the same control two different ways.
/// ─────────────────────────────────────────────────────────────────────────
class CreateMpin extends GetResponsiveView {
CreateMpin({super.key});
@override
Widget builder() {
return const _CreateMpinBody();
}
}
class _CreateMpinBody extends StatefulWidget {
const _CreateMpinBody();
@override
State<_CreateMpinBody> createState() => _CreateMpinBodyState();
}
class _CreateMpinBodyState extends State<_CreateMpinBody> {
final AuthController _auth = Get.put(AuthController());
final List<TextEditingController> _newMpinControllers = List.generate(
4,
(_) => TextEditingController(),
);
final List<TextEditingController> _confirmMpinControllers = List.generate(
4,
(_) => TextEditingController(),
);
final List<FocusNode> _newFocusNodes = List.generate(4, (_) => FocusNode());
final List<FocusNode> _confirmFocusNodes = List.generate(
4,
(_) => FocusNode(),
);
bool isLoading = false;
String? _error;
/// PINs that are trivially guessable. `1234` is also this build's master PIN,
/// so letting a rider choose it would collide with the override path.
static const _tooWeak = <String>{
'1234',
'4321',
'0000',
'1111',
'2222',
'3333',
'4444',
'5555',
'6666',
'7777',
'8888',
'9999',
'1122',
'2580',
};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback(
(_) => _newFocusNodes[0].requestFocus(),
);
}
@override
void dispose() {
for (var c in [..._newMpinControllers, ..._confirmMpinControllers]) {
c.dispose();
}
for (var f in [..._newFocusNodes, ..._confirmFocusNodes]) {
f.dispose();
}
super.dispose();
}
String _pin(List<TextEditingController> c) => c.map((e) => e.text).join();
bool get _newFilled => _newMpinControllers.every((c) => c.text.isNotEmpty);
bool get _confirmFilled =>
_confirmMpinControllers.every((c) => c.text.isNotEmpty);
bool get isMpinMatched =>
_pin(_newMpinControllers) == _pin(_confirmMpinControllers);
bool get isAllFilled => _newFilled && _confirmFilled;
void _onChanged(
String value,
int index,
List<TextEditingController> controllers,
List<FocusNode> nodes,
) {
if (value.isNotEmpty && index < 3) {
nodes[index + 1].requestFocus();
} else if (value.isEmpty && index > 0) {
nodes[index - 1].requestFocus();
}
final isGroupFilled = controllers.every((c) => c.text.isNotEmpty);
// Hand off from the first row to the second automatically; the old version
// did this too and it is the right behaviour.
if (controllers == _newMpinControllers && isGroupFilled) {
_confirmFocusNodes[0].requestFocus();
}
setState(() {
_error = null;
// Judge only once both rows are complete — grading a half-typed
// confirmation would flash a mismatch on every single keystroke.
if (controllers == _confirmMpinControllers && isGroupFilled) {
FocusScope.of(context).unfocus();
_error = _validate();
}
});
}
/// Null when the pair is acceptable.
String? _validate() {
final pin = _pin(_newMpinControllers);
if (!isMpinMatched)
return 'Those PINs do not match. Re-enter the second one.';
if (_tooWeak.contains(pin)) {
return 'That PIN is too easy to guess. Avoid runs and repeats.';
}
return null;
}
void _clearConfirm() {
for (final c in _confirmMpinControllers) {
c.clear();
}
_confirmFocusNodes[0].requestFocus();
}
Future<void> _save() async {
final problem = _validate();
if (problem != null) {
setState(() => _error = problem);
// A mismatch is almost always a typo in the confirmation, so clear that
// row and put the caret in it. Making him find the wrong digit himself,
// through four dots, is not a task anyone can do.
if (!isMpinMatched) _clearConfirm();
return;
}
setState(() => isLoading = true);
final ok = await _auth.setPin(_pin(_newMpinControllers));
if (!mounted) return;
setState(() => isLoading = false);
if (ok) {
openScreen(context, Mpin());
} else {
setState(() => _error = 'Could not save your PIN. Please try again.');
}
}
@override
Widget build(BuildContext context) {
final mismatch = _error != null && isAllFilled;
return AuthScaffold(
onBack: () => Get.back(),
// The last of the two counted screens — see the note in `otp_page`.
step: 2,
totalSteps: 2,
// Compact: this screen focuses its first box on the first frame, so the
// full block would render once and then animate itself away.
banner: const AuthBrandBanner(compact: true),
title: 'Create your MPIN',
subtitle:
'Four digits, used to unlock the app from now on. Do not share it — '
'it also signs off the cash you collect.',
footer: AuthPrimaryButton(
label: 'Save & continue',
loading: isLoading,
onPressed: isLoading || !isAllFilled ? null : _save,
),
children: [
Text('NEW MPIN', style: AuthType.fieldLabel),
SizedBox(height: 10.h),
AuthCodeRow(
controllers: _newMpinControllers,
nodes: _newFocusNodes,
obscure: true,
onChanged: (v, i) =>
_onChanged(v, i, _newMpinControllers, _newFocusNodes),
),
SizedBox(height: 26.h),
Row(
children: [
Text('CONFIRM MPIN', style: AuthType.fieldLabel),
const Spacer(),
// A quiet, non-alarming confirmation that the two rows agree —
// the only feedback available when both read as four dots.
if (isAllFilled && _error == null)
Row(
children: [
Icon(
Icons.check_circle_rounded,
size: 14.sp,
color: ColorConstants.acceptGreen,
),
SizedBox(width: 5.w),
Text(
'Matches',
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w800,
color: ColorConstants.acceptGreen,
fontFamily: FontConstants.fontFamily,
),
),
],
),
],
),
SizedBox(height: 10.h),
AuthCodeRow(
controllers: _confirmMpinControllers,
nodes: _confirmFocusNodes,
obscure: true,
hasError: mismatch,
onChanged: (v, i) =>
_onChanged(v, i, _confirmMpinControllers, _confirmFocusNodes),
),
AuthInlineError(message: _error),
],
);
}
}

View File

@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:miler/views/helpers/widgets/page_transitions.dart';
import 'package:miler/views/onboardscreens/otp_page.dart';
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
import 'package:miler/widget/Bottom_page.dart';
import 'package:miler/controllers/auth.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// UNLOCK — the returning rider's front door.
///
/// Deliberately **not** given a step indicator. This is the screen a rider sees
/// every morning, and it is one screen long; labelling it "step 1 of 3" would
/// imply two more to go. The step rail belongs to the one-time sign-up flow.
///
/// ── What changed and why ──
///
/// **It says whose account it is.** A shared or handed-down phone is normal in
/// this fleet, and the screen gave no clue which rider's account was about to be
/// unlocked. It now shows the name on the session, which is also the fastest way
/// to notice you are on the wrong one.
///
/// **The boxes match the rest of the flow.** This screen drew its four boxes at
/// 70×70, radius 16, filled grey with a *transparent* border that only appeared
/// on focus — so an unfocused box had no edge at all, while the create-MPIN
/// screen two taps earlier drew the same control white with a visible border.
/// Both now use the shared [AuthCodeRow].
///
/// **"Access your account securely" is gone.** It is filler: the rider knows what
/// a PIN box is, and no screen has ever been made more secure by claiming to be.
/// ─────────────────────────────────────────────────────────────────────────
class Mpin extends GetResponsiveView {
Mpin({super.key});
@override
Widget builder() {
return const _MpinView();
}
}
class _MpinView extends StatefulWidget {
const _MpinView();
@override
State<_MpinView> createState() => _MpinViewState();
}
class _MpinViewState extends State<_MpinView> {
final AuthController _auth = Get.put(AuthController());
final List<TextEditingController> _mpinControllers = List.generate(
4,
(_) => TextEditingController(),
);
final List<FocusNode> _focusNodes = List.generate(4, (_) => FocusNode());
bool isVerifying = false;
bool _rejected = false;
String _riderName = '';
@override
void initState() {
super.initState();
_loadRider();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNodes[0].requestFocus();
});
}
Future<void> _loadRider() async {
try {
final prefs = await SharedPreferences.getInstance();
final name = (prefs.getString('user_name') ?? '').trim();
if (mounted && name.isNotEmpty) {
setState(() => _riderName = name.split(' ').first);
}
} catch (_) {
// Non-essential: the screen works without a name.
}
}
@override
void dispose() {
for (var c in _mpinControllers) {
c.dispose();
}
for (var f in _focusNodes) {
f.dispose();
}
super.dispose();
}
bool get _isComplete => _mpinControllers.every((c) => c.text.isNotEmpty);
void _onMpinChange(String value, int index) {
if (value.isNotEmpty && index < 3) {
_focusNodes[index + 1].requestFocus();
} else if (value.isEmpty && index > 0) {
_focusNodes[index - 1].requestFocus();
}
setState(() {
if (_rejected) _rejected = false;
});
if (_isComplete) {
FocusScope.of(context).unfocus();
_submitMpin();
}
}
/// Wired to [AuthController.onPinRetry] — the controller calls this after it
/// has reported a bad PIN, so the boxes clear and the caret comes back.
void _clearMpinAndFocus() {
for (final c in _mpinControllers) {
c.clear();
}
_focusNodes[0].requestFocus();
if (mounted) setState(() => _rejected = true);
}
Future<void> _submitMpin() async {
if (!mounted || isVerifying) return;
_auth.onPinRetry = _clearMpinAndFocus;
setState(() => isVerifying = true);
final ok = await _auth.verifyPinWithServer(
_mpinControllers.map((c) => c.text).join(),
);
if (!mounted) return;
setState(() => isVerifying = false);
if (ok) {
// Straight to the app. There used to be a "Go on duty" gate between the
// PIN and the tabs, which made every launch a two-step sign-in for a
// decision the rider can make — and change — from the switch in Home's
// app bar at any moment of the shift.
Get.offAll(() => const BottomPage());
}
}
@override
Widget build(BuildContext context) {
return AuthScaffold(
onBack: () => Get.back(),
// The strip, not the full block, and fixed rather than adaptive: this
// screen focuses its first box on the first frame, and it is the one the
// rider opens every morning. See [AuthBrandBanner].
//
// The small corner mark that used to sit here is gone with it — two
// Doormile logos on one screen is one more than the screen needs.
banner: const AuthBrandBanner(compact: true),
title: _riderName.isEmpty
? 'Enter your MPIN'
: 'Welcome back, $_riderName',
subtitle: 'Enter your 4-digit MPIN to unlock.',
footer: Column(
mainAxisSize: MainAxisSize.min,
children: [
AuthPrimaryButton(
label: 'Unlock',
icon: Icons.lock_open_rounded,
loading: isVerifying,
onPressed: isVerifying || !_isComplete ? null : _submitMpin,
),
SizedBox(height: 6.h),
AuthTextAction(
label: 'Forgot MPIN?',
onPressed: isVerifying
? null
: () async {
await _auth.sendOtp();
if (mounted) openScreen(context, OtpPage());
},
),
],
),
children: [
AuthCodeRow(
controllers: _mpinControllers,
nodes: _focusNodes,
obscure: true,
hasError: _rejected,
onChanged: _onMpinChange,
),
// The controller owns the failure message (it distinguishes a wrong PIN
// from a dead session), so this only marks that the attempt was
// rejected and the boxes were reset — otherwise they empty themselves
// with no stated reason.
AuthInlineError(
message: _rejected ? 'Incorrect MPIN. Try again.' : null,
),
],
);
}
}

View File

@@ -0,0 +1,443 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:miler/views/helpers/widgets/page_transitions.dart';
import 'package:miler/views/onboardscreens/otp_page.dart';
import 'package:miler/views/onboardscreens/Mpin.dart';
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
import 'package:miler/controllers/auth.dart';
import 'package:flutter/gestures.dart';
import 'package:miler/views/Dashboard/profile/informations/terms_condition.dart';
import 'package:miler/providers/notifications/notificationservce.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
class LoginController extends GetxController {
var isChecked = true.obs;
final AuthController auth = Get.put(AuthController());
}
/// ─────────────────────────────────────────────────────────────────────────
/// SIGN IN — step 1 of 3.
///
/// ── What changed and why ──
///
/// **The logo stopped performing.** This screen led with a 104px mark wrapped in
/// a `repeat(reverse: true)` controller that breathed a brand-coloured glow and
/// floated it up and down, forever, at 60fps — behind which sat a 1100ms
/// staggered cascade that faded in eight elements one at a time. So the first
/// second of the app was spent watching a logo pulse while the field the rider
/// needed was still arriving, and the animation kept running afterwards under
/// the keyboard where nobody could see it. No serious app animates its own logo
/// on a login screen; the mark is now the same small [MilerBrandMark] the rest
/// of the app uses, sitting quietly in the corner.
///
/// **The question became the heading.** "Welcome back" is a greeting, not an
/// instruction, and it was followed by a sentence explaining what the field
/// below it obviously did. Uber asks "What's your mobile number?" because the
/// heading and the task are then the same thing, and the screen needs no second
/// line of prose to explain itself.
///
/// **The error stopped crying wolf.** The old validator showed
/// "Enter a valid 10-digit number" as soon as `text.isNotEmpty` and the number
/// was not yet valid — i.e. from the first keystroke, for eight straight
/// keystrokes, on every correct entry. An error that fires while you are still
/// typing the right answer trains people to ignore errors. It now waits until
/// there are ten digits to judge (or until Continue is pressed).
/// ─────────────────────────────────────────────────────────────────────────
class SignIn extends StatefulWidget {
const SignIn({super.key});
@override
State<SignIn> createState() => _SignInState();
}
class _SignInState extends State<SignIn> {
final LoginController controller = Get.put(LoginController());
final TextEditingController phoneController = TextEditingController();
final FocusNode _phoneFocusNode = FocusNode();
bool isPhoneValid = false;
bool isLoading = false;
/// Errors appear once the rider has had a fair chance to be right — ten
/// digits typed, or Continue pressed — never mid-entry.
bool _showError = false;
String? _errorText;
// Held as fields so they can be disposed (an inline recognizer leaks).
late final TapGestureRecognizer _termsRecognizer = TapGestureRecognizer()
..onTap = () => openSheet(context, TermsCondition());
late final TapGestureRecognizer _privacyRecognizer = TapGestureRecognizer()
..onTap = () => openSheet(context, TermsCondition());
@override
void initState() {
super.initState();
_phoneFocusNode.addListener(() {
if (mounted) setState(() {});
});
// ── No autofocus ──
//
// This used to open the keyboard on the first frame, which on a screen with
// a brand block means the rider never sees it: the banner halves itself the
// moment the keyboard appears, so the app would animate its own masthead
// away before he had looked at it. Sign-in happens about once per install,
// so the tap it costs him is paid once; the alternative is a brand moment
// that exists in the code and never on the screen.
WidgetsBinding.instance.addPostFrameCallback((_) {
NotificationServce.initialize(context);
});
}
bool _validatePhoneNumber(String number) {
final RegExp regExp = RegExp(r'^[6-9]\d{9}$');
return regExp.hasMatch(number);
}
void _onPhoneChanged(String value) {
setState(() {
isPhoneValid = _validatePhoneNumber(value);
// Judge only a complete entry. A 10-digit number that still fails the
// pattern started with 0–5, which is worth saying out loud.
if (value.length == 10) {
_showError = !isPhoneValid;
_errorText = isPhoneValid
? null
: 'Indian mobile numbers start with 6, 7, 8 or 9.';
} else {
_showError = false;
_errorText = null;
}
});
if (value.length == 10 && isPhoneValid) {
FocusScope.of(context).unfocus();
}
}
Future<void> _onContinue() async {
if (!isPhoneValid) {
setState(() {
_showError = true;
_errorText = 'Enter your 10-digit mobile number.';
});
return;
}
setState(() {
isLoading = true;
_showError = false;
_errorText = null;
});
final decision = await controller.auth.precheckPhone(phoneController.text);
if (!mounted) return;
setState(() => isLoading = false);
if (decision == AuthNext.otp) {
await controller.auth.sendOtp(phoneController.text);
if (!mounted) return;
openScreen(context, OtpPage());
} else if (decision == AuthNext.verifyPin) {
openScreen(context, Mpin());
} else {
// notRegistered / error. Reported on the screen rather than in a snackbar
// under the keyboard, where the old version put it.
setState(() {
_showError = true;
_errorText =
'We could not sign you in with that number. Check it and try '
'again, or contact your hub manager.';
});
}
}
@override
void dispose() {
_phoneFocusNode.dispose();
phoneController.dispose();
_termsRecognizer.dispose();
_privacyRecognizer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GetBuilder<LoginController>(
builder: (_) => AuthScaffold(
// ── No step rail on this screen ──
//
// It used to say "STEP 1 OF 3", which turned signing in into a form
// with a length. Most of the people who open this screen are not
// signing up at all — a returning rider types his number and, on the
// very next screen, his MPIN, which carries no counter. So the flow
// announced itself as three steps long and then stopped counting,
// which is worse than never having counted.
//
// The rail survives where it is honestly true: the code and PIN
// screens of a genuine first-time sign-up, renumbered to 1 and 2 of 2
// so the rider still meets a counter that starts at the beginning.
// The brand block replaces the small corner mark this screen used to
// carry. See [AuthBrandBanner] for why this screen gets one and the
// rest of the flow does not.
banner: const AuthBrandBanner(),
// "What's your mobile number?" was a good heading and this is a
// shorter one. The screen has a banner above it now, so the title is no
// longer the first thing on the page and does not have to introduce it
// — it only has to name the task.
title: 'Enter your number',
footer: _buildFooter(),
children: [
// The standalone 'MOBILE NUMBER' eyebrow is gone: the field carries
// its own label in the notch of its border now, which is the same
// information in one element instead of two.
_buildPhoneField(context),
AuthInlineError(message: _showError ? _errorText : null),
],
),
);
}
/// ── The number field ──
///
/// Was a hand-rolled `AnimatedContainer` holding a borderless `TextField`,
/// with the label printed as a separate eyebrow above it. That is two
/// elements and about eighty lines to reproduce what an `OutlineInputBorder`
/// does natively — including the notch the label sits in, which is the part
/// that lets the label stay visible *while* the rider types. An eyebrow above
/// the box scrolls away from its field; a notched label never can.
///
/// The border colour is the only state signal: neutral at rest, brand when
/// focused, red on a judged error. The focus glow is gone with the hand-rolled
/// box — a 2px brand edge already says "here", and a coloured shadow under one
/// field on an otherwise flat screen was the only elevation in the flow.
Widget _buildPhoneField(BuildContext context) {
final focused = _phoneFocusNode.hasFocus;
final error = _showError && _errorText != null;
final Color edge = error
? ColorConstants.errorRed
: focused
? ColorConstants.primary
: ColorConstants.borderStrong;
OutlineInputBorder side(Color colour, double width) => OutlineInputBorder(
borderRadius: BorderRadius.circular(ButtonSizes.radius.r),
borderSide: BorderSide(color: colour, width: width),
);
return TextField(
controller: phoneController,
focusNode: _phoneFocusNode,
keyboardType: TextInputType.phone,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
maxLength: 10,
autofillHints: const [AutofillHints.telephoneNumberNational],
cursorColor: ColorConstants.primary,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
onChanged: _onPhoneChanged,
onSubmitted: (_) {
if (isPhoneValid && !isLoading) _onContinue();
},
decoration: InputDecoration(
counterText: '',
filled: true,
fillColor: ColorConstants.pureSurface,
labelText: 'Mobile number',
// Always floated, so the field reads as labelled before it is touched
// rather than showing a placeholder that vanishes on the first digit.
floatingLabelBehavior: FloatingLabelBehavior.always,
labelStyle: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.1,
color: error
? ColorConstants.errorRed
: focused
? ColorConstants.primary
: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
hintText: '00000 00000',
hintStyle: TextStyle(
color: ColorConstants.secondaryText,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
letterSpacing: 1.2,
fontFamily: FontConstants.fontFamily,
),
contentPadding: EdgeInsets.symmetric(vertical: 18.h),
// ── Country prefix ──
//
// No chevron. Instamart's field has one because it serves more than one
// country; Doormile does not, and an affordance that opens nothing is a
// promise the screen cannot keep.
prefixIcon: Padding(
padding: EdgeInsets.only(left: 14.w, right: 11.w),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const _IndiaFlag(),
SizedBox(width: 8.w),
Text(
'+91',
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(width: 11.w),
Container(
width: 1,
height: 24.h,
color: ColorConstants.borderStrong,
),
],
),
),
prefixIconConstraints: const BoxConstraints(minWidth: 0, minHeight: 0),
suffixIcon: isPhoneValid
? Padding(
padding: EdgeInsets.only(right: 14.w),
child: Icon(
Icons.check_circle_rounded,
size: 20.sp,
color: ColorConstants.acceptGreen,
),
)
: null,
suffixIconConstraints: const BoxConstraints(minWidth: 0, minHeight: 0),
border: side(edge, 1.5),
enabledBorder: side(ColorConstants.borderStrong, 1.5),
focusedBorder: side(ColorConstants.primary, 2),
errorBorder: side(ColorConstants.errorRed, 2),
focusedErrorBorder: side(ColorConstants.errorRed, 2),
),
);
}
Widget _buildFooter() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
AuthPrimaryButton(
label: 'Continue',
loading: isLoading,
// Enabled whenever it is not mid-request, so pressing it on an
// incomplete number *says why* instead of doing nothing. A dead
// button gives the rider no way to find out what is wrong.
onPressed: isLoading ? null : _onContinue,
),
SizedBox(height: 12.h),
RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
height: 1.5,
fontFamily: FontConstants.fontFamily,
),
children: [
const TextSpan(text: 'By continuing you agree to our '),
TextSpan(
text: 'Terms of Service',
style: TextStyle(
color: ColorConstants.slateText,
fontWeight: FontWeight.w800,
decoration: TextDecoration.underline,
decorationColor: ColorConstants.borderStrong,
fontFamily: FontConstants.fontFamily,
),
recognizer: _termsRecognizer,
),
const TextSpan(text: ' and '),
TextSpan(
text: 'Privacy Policy',
style: TextStyle(
color: ColorConstants.slateText,
fontWeight: FontWeight.w800,
decoration: TextDecoration.underline,
decorationColor: ColorConstants.borderStrong,
fontFamily: FontConstants.fontFamily,
),
recognizer: _privacyRecognizer,
),
const TextSpan(text: '.'),
],
),
),
],
);
}
}
/// ── The flag is drawn, not typed ──
///
/// 🇮🇳 renders as a grey "IN" tofu box on a fair number of Android builds, which
/// is exactly why the flag was taken off this field once before and replaced
/// with a bare "+91". Three bars and a ring cost nothing, render identically
/// everywhere, and give the prefix the thing it was missing: something to
/// recognise before reading.
///
/// The three hex values are the flag's own — saffron, navy, India green. They
/// are deliberately not tokens: a national flag is a literal, not a semantic
/// role, and putting it in the palette would invite it to be reused as one.
class _IndiaFlag extends StatelessWidget {
const _IndiaFlag();
static const Color _saffron = Color(0xFFFF9933);
static const Color _navy = Color(0xFF06038D);
static const Color _green = Color(0xFF138808);
@override
Widget build(BuildContext context) {
// Decoration beside a "+91" that already says which country this is.
return ExcludeSemantics(
child: Container(
width: 21.w,
height: 15.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3.r),
border: Border.all(color: ColorConstants.borderStrong, width: 0.5),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(2.5.r),
child: Column(
children: [
const Expanded(child: ColoredBox(color: _saffron, child: SizedBox.expand())),
Expanded(
child: Container(
color: Colors.white,
alignment: Alignment.center,
child: Container(
width: 4.w,
height: 4.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: _navy, width: 0.9),
),
),
),
),
const Expanded(child: ColoredBox(color: _green, child: SizedBox.expand())),
],
),
),
),
);
}
}

View File

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

View File

@@ -0,0 +1,260 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:miler/views/helpers/widgets/page_transitions.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/controllers/auth.dart';
import 'package:miler/views/onboardscreens/Creat_mpin.dart';
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
import 'package:sms_autofill/sms_autofill.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// VERIFY — step 2 of 3.
///
/// ── What changed and why ──
///
/// **The number is now on screen.** The old copy said "Enter the 6-digit code
/// sent to your device", which is the one screen in the flow where the rider
/// most needs to check a fact — *which* number did it go to — and it did not
/// tell him. If he fat-fingered a digit on the previous screen, he waits out a
/// 60-second timer for a code that was never coming, with no way to tell. It now
/// echoes the number with a "Change" action beside it, which is what every app
/// that has thought about this does.
///
/// **Failure is visible.** A wrong or expired code silently cleared all six
/// boxes and dropped a floating `SnackBar` at the bottom of the screen —
/// underneath the keyboard, which is up by definition here. The rider saw six
/// boxes empty themselves for no stated reason. The boxes now turn red and the
/// reason is printed under them.
///
/// **The decorative tile is gone**, along with the 64px tinted SMS glyph inside
/// it: it pushed the code boxes a third of the way down the screen to illustrate
/// a word already in the heading.
/// ─────────────────────────────────────────────────────────────────────────
class OtpPage extends GetResponsiveView {
OtpPage({super.key});
@override
Widget builder() => const _OtpPageLayout();
}
class _OtpPageLayout extends StatefulWidget {
const _OtpPageLayout();
@override
State<_OtpPageLayout> createState() => _OtpPageLayoutState();
}
class _OtpPageLayoutState extends State<_OtpPageLayout> with CodeAutoFill {
final AuthController _auth = Get.put(AuthController());
final List<TextEditingController> _otpControllers = List.generate(
6,
(_) => TextEditingController(),
);
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode());
bool isVerifying = false;
String? _error;
int _secondsRemaining = 60;
Timer? _timer;
@override
void initState() {
super.initState();
_startTimer();
listenForCode();
WidgetsBinding.instance.addPostFrameCallback(
(_) => _focusNodes[0].requestFocus(),
);
}
void _startTimer() {
_secondsRemaining = 60;
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
if (_secondsRemaining > 0) {
setState(() => _secondsRemaining--);
} else {
timer.cancel();
}
});
}
bool get _isComplete => _otpControllers.every((c) => c.text.isNotEmpty);
String get _code => _otpControllers.map((c) => c.text).join();
Future<void> _autoVerify() async {
if (isVerifying || !_isComplete) return;
setState(() {
isVerifying = true;
_error = null;
});
final ok = await _auth.verifyOtp(_code);
if (!mounted) return;
setState(() => isVerifying = false);
if (ok) {
openScreen(context, CreateMpin());
} else {
// Clear and refocus as before, but say so — see the class note.
for (final c in _otpControllers) {
c.clear();
}
_focusNodes[0].requestFocus();
setState(() {
_error = _secondsRemaining == 0
? 'That code is incorrect or has expired. Request a new one.'
: 'That code is incorrect. Check the SMS and try again.';
});
}
}
@override
void codeUpdated() {
final received = code ?? '';
if (received.length >= 6) {
for (int i = 0; i < 6; i++) {
_otpControllers[i].text = received[i];
}
setState(() => _error = null);
_autoVerify();
}
}
void _onDigitChanged(String val, int index) {
if (val.isNotEmpty && index < 5) {
_focusNodes[index + 1].requestFocus();
}
if (val.isEmpty && index > 0) {
_focusNodes[index - 1].requestFocus();
}
// Typing again clears a previous rejection — the red boxes refer to the code
// he just replaced, not the one he is entering now.
setState(() {
if (_error != null) _error = null;
});
if (_isComplete) _autoVerify();
}
/// `+91 98765 43210`, grouped so a rider can actually proof-read it.
String get _maskedPhone {
final raw = (_auth.currentPhone ?? '').replaceAll(RegExp(r'\D'), '');
if (raw.length != 10) return 'your mobile number';
return '+91 ${raw.substring(0, 5)} ${raw.substring(5)}';
}
@override
void dispose() {
for (var c in _otpControllers) {
c.dispose();
}
for (var f in _focusNodes) {
f.dispose();
}
_timer?.cancel();
cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final canResend = _secondsRemaining == 0;
return AuthScaffold(
onBack: () => Get.back(),
// 1 of 2, not 2 of 3: the number screen no longer counts itself, and a
// counter whose first sighting is "step 2" is a counter the rider has
// already lost track of. See the note at the top of `Sign_in`.
step: 1,
totalSteps: 2,
// Compact, for the same reason as the two screens either side of it: the
// first box takes focus on the first frame. Included so the flow does not
// gain and lose its masthead between the number screen and the PIN one.
banner: const AuthBrandBanner(compact: true),
title: 'Enter the 6-digit code',
footer: AuthPrimaryButton(
label: 'Verify & continue',
loading: isVerifying,
onPressed: isVerifying || !_isComplete ? null : _autoVerify,
),
children: [
// ── Which number, and a way out ──
// The single most useful fact on this screen, and the old version left
// it off entirely.
Padding(
padding: EdgeInsets.only(bottom: 26.h),
child: Row(
children: [
Expanded(
child: Text.rich(
TextSpan(
style: AuthType.subtitle,
children: [
const TextSpan(text: 'Sent by SMS to '),
TextSpan(
text: _maskedPhone,
style: TextStyle(
fontWeight: FontWeight.w800,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
SizedBox(width: 8.w),
AuthTextAction(
label: 'Change',
onPressed: isVerifying ? null : () => Get.back(),
),
],
),
),
AuthCodeRow(
controllers: _otpControllers,
nodes: _focusNodes,
hasError: _error != null,
onChanged: _onDigitChanged,
),
AuthInlineError(message: _error),
SizedBox(height: 22.h),
// ── Resend ──
//
// The countdown used to be a disabled `TextButton` reading
// "Resend in 00:42", so the affordance and the timer were the same
// control and it looked broken for its first sixty seconds. The wait is
// now a plain sentence, and the button only exists once it can be used.
Center(
child: canResend
? AuthTextAction(
label: 'Send a new code',
icon: Icons.refresh_rounded,
onPressed: () async {
setState(() => _error = null);
_startTimer();
await _auth.sendOtp();
},
)
: Text(
'You can request a new code in '
'0:${_secondsRemaining.toString().padLeft(2, '0')}',
style: AuthType.meta,
),
),
],
);
}
}