Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
232 lines
7.9 KiB
Dart
232 lines
7.9 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/views/onboardscreens/otp_page.dart';
|
||
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
|
||
import 'package:miler/helpers/rider_shell.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;
|
||
|
||
/// What actually went wrong, from [AuthController.lastPinFailure].
|
||
///
|
||
/// This screen used to hard-code "Incorrect MPIN. Try again." for every
|
||
/// failure the controller reported — including the ones that never reached
|
||
/// the server. A rider whose sign-in was dying on a device-id lookup was told
|
||
/// his PIN was wrong, so the one thing he could report was the one thing that
|
||
/// was not true. Whatever the controller knows, the screen now says.
|
||
String? _failure;
|
||
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;
|
||
_failure = null;
|
||
}
|
||
});
|
||
|
||
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;
|
||
_failure = _auth.lastPinFailure;
|
||
});
|
||
}
|
||
}
|
||
|
||
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;
|
||
// Say it on the screen, not only inside a sheet the rider dismisses.
|
||
if (!ok) {
|
||
_rejected = true;
|
||
_failure = _auth.lastPinFailure;
|
||
}
|
||
});
|
||
|
||
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.
|
||
//
|
||
// Which app depends on the tenant the login just resolved: `loginParsed`
|
||
// persists `tenantid`/`tenantname` and calls `TenantController.load()`
|
||
// before returning, so by this line the answer is settled. A delivery
|
||
// rider lands in the ported Xpress flow, everyone else in the parcel
|
||
// dashboard. See [riderShell].
|
||
Get.offAll(() => riderShell());
|
||
}
|
||
}
|
||
|
||
@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: LucideIcons.lockOpen,
|
||
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
|
||
? (_failure ?? 'Incorrect MPIN. Try again.')
|
||
: null,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|