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,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())),
],
),
),
),
);
}
}