Files
2026-08-11 13:16:33 +05:30

261 lines
8.7 KiB
Dart

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