Three fixes found by running the app on a real handset against production. 1. An expired token left the app looking signed in and unable to work. MilerApi.onUnauthorized was declared and called on every 401 but never assigned, so the token was dropped and nothing else happened: the profile stayed on disk, logged_out stayed false, and the rider saw his own name over a dashboard whose every call returned 401. He reads that as "no work today". The teardown now lives in endSession() and both ways out of a session — the Log out button and the 401 path — use it. 2. Arrived was written locally even when the rider was not there. updateArrivedStatus answers false for three different things and the caller treated all of them as "the write did not land", which is only true of one. A geofence refusal and a server refusal now stop the rung and hand back the reason; a dead network still advances, as it should. 3. A multi-destination customer pickup collapsed onto one stop. GET /miler/bookings returns a row per destination once collected, all with the same bookingid and reference. Every local store keys on that id, so the accepted store deduped two of three drops away and their consignment ids were unrecoverable. orderid is now the stop key; bookingreference stays the booking's name. Cards show "Stop 2 of 3" and the receiver's own name and number rather than the sender's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
288 lines
9.7 KiB
Dart
288 lines
9.7 KiB
Dart
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<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);
|
|
|
|
// ── 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),
|
|
],
|
|
);
|
|
}
|
|
}
|