first commit
This commit is contained in:
260
lib/views/onboardscreens/Creat_mpin.dart
Normal file
260
lib/views/onboardscreens/Creat_mpin.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
198
lib/views/onboardscreens/Mpin.dart
Normal file
198
lib/views/onboardscreens/Mpin.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
443
lib/views/onboardscreens/Sign_in.dart
Normal file
443
lib/views/onboardscreens/Sign_in.dart
Normal 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())),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
986
lib/views/onboardscreens/auth_scaffold.dart
Normal file
986
lib/views/onboardscreens/auth_scaffold.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
260
lib/views/onboardscreens/otp_page.dart
Normal file
260
lib/views/onboardscreens/otp_page.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user