import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package: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 createState() => _SignInState(); } class _SignInState extends State { 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 _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( 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.w600, 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.w600, 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.w500, 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.w700, 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( LucideIcons.circleCheck, 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.w700, 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.w700, 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()), ), ], ), ), ), ); } }