Files
doormile_milderapp/lib/views/onboardscreens/Sign_in.dart
2026-08-11 13:16:33 +05:30

444 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())),
],
),
),
),
);
}
}