449 lines
17 KiB
Dart
449 lines
17 KiB
Dart
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<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 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.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()),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|