Files
doormile_milderapp/lib/views/onboardscreens/Mpin.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
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>
2026-08-22 05:40:35 +05:30

232 lines
7.9 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
],
);
}
}