import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.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/widget/Bottom_page.dart'; import 'package:miler/views/helpers/widgets/app_widgets.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 _newMpinControllers = List.generate( 4, (_) => TextEditingController(), ); final List _confirmMpinControllers = List.generate( 4, (_) => TextEditingController(), ); final List _newFocusNodes = List.generate(4, (_) => FocusNode()); final List _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 = { '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 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 controllers, List 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 _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); // ── Setting the PIN IS the sign-in ── // // `POST /miler/set-pin` returns a full session — token and user — so there // is no verify-pin afterwards and no second PIN to type. This used to push // the rider to the unlock screen to enter the PIN he had just chosen, which // was the only thing it could do while the write had no route behind it. if (ok) { Get.offAll(() => const BottomPage()); return; } // A 409 means he is not a first-time rider after all — he already has a // PIN, here or on another handset. Sending him to Enter-PIN is the useful // answer; "could not save your PIN" would leave him retyping one the // server will never accept. if (_auth.lastSetPinWasAlreadySet) { if (_auth.lastSetPinFailure != null) { AppFeedback.infoGlobal(_auth.lastSetPinFailure!); } openScreen(context, Mpin()); return; } setState( () => _error = _auth.lastSetPinFailure ?? '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( LucideIcons.circleCheck, 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), ], ); } }