Files
doormile_milderapp/lib/controllers/auth.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

445 lines
18 KiB
Dart

import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:get/get.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/providers/auth/auth_provider.dart';
import 'package:miler/utils/device.dart';
import 'package:miler/controllers/profile_controller.dart';
import 'package:miler/Models/login/login.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
enum AuthNext { verifyPin, otp, notRegistered, error }
class AuthController extends GetxController {
final RxBool sendingOtp = false.obs;
String? currentPhone;
final AuthProvider _api = AuthProvider();
AuthNext? lastDecision;
// Optional callback used by MPIN screen to clear and refocus fields when user taps "Retry"
VoidCallback? onPinRetry;
/// Why the last [verifyPinWithServer] failed, in the rider's words.
///
/// ── "Incorrect MPIN" was the answer to every question ──
///
/// The MPIN screen painted that one line whenever the controller reported a
/// failure — a wrong PIN, a dead network, a 500, and (for a long time) a
/// device-id lookup that threw before the request was sent. So the one
/// symptom a rider could report was the one cause that was often not true,
/// and there was no way to tell a mistyped PIN from an app that was never
/// going to reach the server.
///
/// Set on every failure path, cleared on success. Read by `Mpin.dart`.
String? lastPinFailure;
static const String _prefsUserIdKey = 'userid';
static const String _prefsPendingPinUserIdKey = 'pending_pin_userid';
static const String _prefsUserNameKey = 'user_name';
static const String _prefsUserEmailKey = 'user_email';
static const String _prefsContactNoKey = 'contactno';
static const String _prefsAddressKey = 'user_address';
static const String _prefsForceMasterPinKey = 'force_master_pin';
static const String _masterPinValue = '1234';
static const String forceMasterPinPrefKey = _prefsForceMasterPinKey;
static const String masterPinValue = _masterPinValue;
bool _forceMasterPinFlow = false;
Future<void> _notifyProfileController() async {
try {
if (Get.isRegistered<ProfileController>()) {
final prefs = await SharedPreferences.getInstance();
final pc = Get.find<ProfileController>();
await pc.loadFromPrefs();
pc.setProfile(
name: prefs.getString(_prefsUserNameKey),
email: prefs.getString(_prefsUserEmailKey),
contact: prefs.getString(_prefsContactNoKey),
address: prefs.getString(_prefsAddressKey),
);
}
} catch (_) {}
}
String _normalizePhone(String input) {
final digitsOnly = input.replaceAll(RegExp(r'\D'), '');
if (digitsOnly.length >= 10) {
return digitsOnly.substring(digitsOnly.length - 10);
}
return digitsOnly;
}
void _showBottomSheet({required String title, required String message}) {
// `Get.bottomSheet` stays (this controller has no BuildContext for the
// kit's presenter), but the surface inside it is the kit's — the same
// glass, handle and insets as every sheet after sign-in, so the first
// sheet a rider ever meets is not the one drawn differently.
Get.bottomSheet(
MilerSheetScaffold(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
MilerSheetHeader(
title: title,
subtitle: message,
icon: LucideIcons.info,
),
const SizedBox(height: 18),
MilerButton(
label: 'Retry',
onPressed: () {
Get.back();
// If MPIN screen has registered a retry callback, run it
onPinRetry?.call();
},
),
],
),
),
isScrollControlled: true,
backgroundColor: Colors.transparent,
);
}
Future<AuthNext> precheckPhone(String phone) async {
try {
final normalized = _normalizePhone(phone);
currentPhone = normalized;
final prefs = await SharedPreferences.getInstance();
// The mocked "Demo Rider" (userid 9999) that used to be written here is
// gone. It bypassed the server entirely and left a fake identity in prefs
// that outlived the session it was created for — every screen reading
// 'userid' got 9999 until the app was reinstalled. The real user is
// established by verify-pin and nowhere else.
await prefs.setString(_prefsContactNoKey, normalized);
// On the live backend, ask whether this phone already belongs to an
// active miler account with a PIN on file. If it does, go straight to the
// MPIN screen: OTP delivery isn't live yet, and the OTP path ends at
// Create-MPIN, which would overwrite the PIN the account was issued.
// Seeded development accounts take exactly this branch — enter the phone,
// enter the seeded MPIN, done.
// Null means the directory could not be reached — see
// [AuthProvider.milerAccountExists]. Treat it as "he has an account",
// because that is true of every rider who gets this far and because the
// MPIN screen is the only one that can tell him what went wrong. The OTP
// branch is the dead end: it ends at Create-MPIN, which cannot write a
// PIN, so guessing wrong in that direction locks a rider out.
final exists = await _api.milerAccountExists(normalized);
if (exists ?? true) {
lastDecision = AuthNext.verifyPin;
return lastDecision!;
}
// The directory answered, and said there is no such account.
lastDecision = AuthNext.otp;
return lastDecision!;
} catch (e) {
debugPrint('Precheck phone error: $e');
lastDecision = AuthNext.error;
return lastDecision!;
}
}
Future<bool> sendOtp([String? phoneArg]) async {
if (sendingOtp.value) return false;
if (phoneArg != null && phoneArg.isNotEmpty) {
currentPhone = _normalizePhone(phoneArg);
}
sendingOtp.value = true;
try {
await Future.delayed(const Duration(milliseconds: 500));
return true;
} finally {
sendingOtp.value = false;
}
}
/// ── There is no OTP route on the backend ──
///
/// This returned true with the comment "automatically succeed for mocked
/// login", which read as leftover demo scaffolding. It is not: `MilerApi`
/// carries the whole auth surface and it is three routes — `login`,
/// `verify-pin`, `device-token`. Nothing verifies a code, so there is nothing
/// for this to call.
///
/// It stays a pass-through for the same reason [AuthProvider.updatePin] does:
/// failing instead would strand a new rider on a screen with no way forward,
/// which is worse and no more honest. What changes is that the gap is now
/// recorded rather than described as a mock, so it shows up in the same place
/// as every other missing route.
Future<bool> verifyOtp(String code) async {
ApiConfig.logGap(
'verifyOtp',
'No OTP verification route exists; the code entered is not checked.',
);
return true;
}
Future<bool> setPin(String newPin) async {
try {
final prefs = await SharedPreferences.getInstance();
int? userId =
prefs.getInt(_prefsPendingPinUserIdKey) ??
prefs.getInt(_prefsUserIdKey);
if (newPin.length != 4 || int.tryParse(newPin) == null) {
_showBottomSheet(
title: 'Invalid PIN',
message: 'Please enter a valid 4-digit PIN.',
);
return false;
}
if (userId == null) {
_showBottomSheet(
title: 'Error',
message: 'User ID not found. Please try again.',
);
return false;
}
final int pinNum = int.parse(newPin);
final res = await _api.updatePin(userId: userId, pin: pinNum);
if (res.statusCode >= 200 && res.statusCode < 300) {
await prefs.setString('dbPin', newPin);
await prefs.remove(_prefsPendingPinUserIdKey);
return true;
}
// The server's own sentence, not a slice of its JSON. A rider reading
// `{"status":false,"code":403,...}` learns nothing he can act on.
String reason = '';
try {
final decoded = json.decode(res.body);
if (decoded is Map) reason = (decoded['message'] ?? '').toString();
} catch (_) {}
if (reason.trim().isEmpty) {
reason = 'Could not set your MPIN. Please contact your hub manager.';
}
_showBottomSheet(title: 'MPIN not changed', message: reason);
return false;
} catch (e) {
debugPrint('setPin error: $e');
_showBottomSheet(
title: 'Error',
message: 'Something went wrong while setting the PIN.',
);
return false;
}
}
/// Refresh session on backend with latest deviceId/FCM for the current phone.
Future<bool> refreshSession({String? phone}) async {
try {
final prefs = await SharedPreferences.getInstance();
final String? usePhone = phone ?? currentPhone;
if (usePhone == null || usePhone.isEmpty) {
return false;
}
final deviceId = await DeviceUtils.ensureDeviceId(prefs);
final fcmToken = await DeviceUtils.ensureFcmToken(prefs);
final Login loginRes = await _api.loginParsed(
contactNo: usePhone,
deviceType: Platform.operatingSystem,
configId: 6,
deviceId: deviceId,
fcmToken: fcmToken,
);
if (loginRes.userid != null) {
await prefs.setInt(_prefsUserIdKey, loginRes.userid!);
}
try {
final String? name = loginRes.fullname ?? loginRes.firstname;
final String? email = loginRes.email;
final String contact = (loginRes.contactno ?? usePhone).toString();
final String? address = loginRes.address;
if (name != null && name.trim().isNotEmpty) {
await prefs.setString(_prefsUserNameKey, name.trim());
}
if (email != null && email.trim().isNotEmpty) {
await prefs.setString(_prefsUserEmailKey, email.trim());
}
if (contact.isNotEmpty) {
final normalizedContact = _normalizePhone(contact);
await prefs.setString(_prefsContactNoKey, normalizedContact);
}
if (address != null && address.trim().isNotEmpty) {
await prefs.setString(_prefsAddressKey, address.trim());
}
await _notifyProfileController();
} catch (_) {}
currentPhone = _normalizePhone(usePhone);
return loginRes.status == true;
} catch (_) {
return false;
}
}
Future<bool> verifyPinWithServer(String inputPin) async {
final prefs = await SharedPreferences.getInstance();
// The mocked-login bypass that used to sit here accepted ANY four digits
// and logged the rider in without asking the server. It is gone: a PIN
// check that cannot fail is not a PIN check.
// Authenticate phone + PIN against POST /miler/verify-pin. Only a real
// success (server ok + bearer token stored) logs the rider in.
try {
final String phone =
currentPhone ?? prefs.getString(_prefsContactNoKey) ?? '';
final int? pinNum = int.tryParse(inputPin);
if (phone.isEmpty || pinNum == null || inputPin.length != 4) {
// Two different faults wearing one message. A missing phone is not the
// rider mistyping — it means he reached this screen without the number
// step, and telling him to re-enter his PIN sends him round a loop that
// cannot end.
lastPinFailure = phone.isEmpty
? 'We lost your phone number. Go back and enter it again.'
: 'Enter all 4 digits of your MPIN.';
_showBottomSheet(title: 'Invalid PIN', message: lastPinFailure!);
return false;
}
// ── Nothing gathered here may stop the sign-in ──
//
// These two lines used to sit bare inside this `try`, and
// `ensureDeviceId` threw on iOS and on any Android that handed back an
// empty id. The throw landed in the catch below, so the rider was told
// "Could not reach the server" — before a single byte had been sent —
// and the MPIN screen then called it an incorrect PIN. Every number,
// every attempt.
//
// `ensureDeviceId` is total now (see [DeviceUtils]), and this second
// guard says why it must stay that way: `deviceId` is not even part of
// the verify-pin body, and a push token the rider declined is not a
// reason to refuse him his shift. Best effort, then post regardless.
String deviceId = '';
String fcmToken = '';
try {
deviceId = await DeviceUtils.ensureDeviceId(prefs);
} catch (e) {
debugPrint('[AUTH] device id unavailable, continuing: $e');
}
try {
fcmToken = await DeviceUtils.ensureFcmToken(prefs);
} catch (e) {
debugPrint('[AUTH] fcm token unavailable, continuing: $e');
}
// ── This attempt is the only thing that may grant a session ──
//
// The check below asks prefs whether a token exists. Without this line
// that question is answered by *any previous session*, so the gate was
// broken in both directions: a stale token made a rejected PIN look
// accepted, and a fresh install with a token the app could not find made
// an accepted PIN look rejected.
await ApiConfig.clearToken();
final Login res = await _api.loginParsed(
contactNo: phone,
deviceType: Platform.operatingSystem,
configId: 6,
deviceId: deviceId,
fcmToken: fcmToken,
pin: pinNum,
pinRaw: inputPin,
);
// ── Three outcomes, not two ──
//
// `res.status` is the server's verdict on the credentials. The token is
// whether this call handed back a session. They are different facts, and
// collapsing them into one boolean is what produced **"Login failed"** on
// a PIN the server had just accepted — the app could not find the token
// in the response, so it reported the rider's PIN as wrong.
final bool serverAccepted = res.status == true;
final String? token = await ApiConfig.getToken();
final bool haveSession = token != null && token.isNotEmpty;
final bool ok = serverAccepted && haveSession;
if (serverAccepted && !haveSession) {
// The credentials were right and there is nothing to sign in with.
// This is an integration fault, not a rider fault, and it must never
// again be reported as a bad PIN. The log line above it names the keys
// the response actually carried.
debugPrint(
'[AUTH] verify-pin accepted the PIN but returned no usable token',
);
lastPinFailure =
'Your MPIN was accepted, but the server did not return a session. '
'Please report this to the hub — it is not your PIN.';
_showBottomSheet(
title: 'Could not start session',
message: lastPinFailure!,
);
return false;
}
if (ok) {
lastPinFailure = null;
await prefs.setString('dbPin', inputPin);
await prefs.setBool('logged_out', false);
currentPhone = _normalizePhone(phone);
// Overwrite any stale demo profile with the REAL logged-in identity.
// The UI reads the display name from 'user_name'; loginParsed only wrote
// 'username'/'firstname', so mirror the real name/email/contact here.
final String realName =
(res.fullname != null && res.fullname!.trim().isNotEmpty)
? res.fullname!.trim()
: (prefs.getString('username') ??
'${res.firstname ?? ''} ${res.lastname ?? ''}')
.trim();
if (realName.isNotEmpty) {
await prefs.setString(_prefsUserNameKey, realName);
}
final String realEmail = (res.email ?? '').trim();
if (realEmail.isNotEmpty) {
await prefs.setString(_prefsUserEmailKey, realEmail);
}
await prefs.setString(_prefsContactNoKey, _normalizePhone(phone));
await _notifyProfileController();
return true;
}
// ── Only 401/403 is a statement about the PIN ──
//
// Everything else — a 502, a gateway timeout, a captive portal, a body
// that is not JSON — is the sign-in failing to *complete*, which is a
// different problem with a different fix. Reporting all of it as a login
// failure is what made a network fault indistinguishable from a wrong
// MPIN, on a screen whose whole job is to tell those apart.
final int status = res.code ?? 0;
final String serverSaid = (res.message ?? '').trim();
final bool aboutTheCredentials = status == 401 || status == 403;
if (aboutTheCredentials) {
lastPinFailure = serverSaid.isNotEmpty
? serverSaid
: 'Incorrect MPIN for $phone. Try again.';
_showBottomSheet(title: 'Login failed', message: lastPinFailure!);
} else {
lastPinFailure = serverSaid.isNotEmpty
? 'Sign-in could not complete. $serverSaid'
: 'Sign-in could not complete (HTTP $status). This is not your '
'MPIN — check the connection and try again.';
_showBottomSheet(
title: 'Sign-in did not complete',
message: lastPinFailure!,
);
}
return false;
} catch (e) {
// Never reached the server, or could not read what came back. Whatever
// this is, it is NOT the rider's PIN, and saying so is the whole point.
debugPrint('verifyPinWithServer error: $e');
lastPinFailure =
'Could not reach the server. Check your connection and try again.';
_showBottomSheet(title: 'Connection error', message: lastPinFailure!);
return false;
}
}
}