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>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
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/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.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';
|
||||
@@ -10,6 +10,7 @@ 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 }
|
||||
|
||||
@@ -20,6 +21,20 @@ class AuthController extends GetxController {
|
||||
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';
|
||||
@@ -56,54 +71,31 @@ class AuthController extends GetxController {
|
||||
}
|
||||
|
||||
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(
|
||||
Builder(
|
||||
builder: (context) => Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: 16 + MediaQuery.of(context).viewPadding.bottom,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: ColorConstants.primary, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
MilerButton(
|
||||
label: 'Retry',
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
// If MPIN screen has registered a retry callback, run it
|
||||
onPinRetry?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
@@ -130,13 +122,19 @@ class AuthController extends GetxController {
|
||||
// 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) {
|
||||
if (exists ?? true) {
|
||||
lastDecision = AuthNext.verifyPin;
|
||||
return lastDecision!;
|
||||
}
|
||||
|
||||
// Unknown number (or legacy backend) — fall through to the OTP step.
|
||||
// The directory answered, and said there is no such account.
|
||||
lastDecision = AuthNext.otp;
|
||||
return lastDecision!;
|
||||
} catch (e) {
|
||||
@@ -160,8 +158,24 @@ class AuthController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
/// ── 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 {
|
||||
// Automatically succeed for mocked login
|
||||
ApiConfig.logGap(
|
||||
'verifyOtp',
|
||||
'No OTP verification route exists; the code entered is not checked.',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -185,15 +199,6 @@ class AuthController extends GetxController {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Demo mode: the mocked login flow stores a fake rider id (9999) that
|
||||
// the live server rejects, so updatePin fails. Save the PIN locally and
|
||||
// report success so the demo Create-MPIN flow works without a real
|
||||
// account or server call.
|
||||
if (userId == 9999) {
|
||||
await prefs.setString('dbPin', newPin);
|
||||
await prefs.remove(_prefsPendingPinUserIdKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
final int pinNum = int.parse(newPin);
|
||||
final res = await _api.updatePin(userId: userId, pin: pinNum);
|
||||
@@ -202,13 +207,17 @@ class AuthController extends GetxController {
|
||||
await prefs.remove(_prefsPendingPinUserIdKey);
|
||||
return true;
|
||||
}
|
||||
final bodyPreview = res.body.length > 200
|
||||
? '${res.body.substring(0, 200)}...'
|
||||
: res.body;
|
||||
_showBottomSheet(
|
||||
title: 'Failed (${res.statusCode})',
|
||||
message: 'Unable to set PIN. Server said: $bodyPreview',
|
||||
);
|
||||
// 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');
|
||||
@@ -281,15 +290,52 @@ class AuthController extends GetxController {
|
||||
currentPhone ?? prefs.getString(_prefsContactNoKey) ?? '';
|
||||
final int? pinNum = int.tryParse(inputPin);
|
||||
if (phone.isEmpty || pinNum == null || inputPin.length != 4) {
|
||||
_showBottomSheet(
|
||||
title: 'Invalid PIN',
|
||||
message: 'Please enter your 4-digit PIN and try again.',
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
|
||||
final deviceId = await DeviceUtils.ensureDeviceId(prefs);
|
||||
final fcmToken = await DeviceUtils.ensureFcmToken(prefs);
|
||||
// ── 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,
|
||||
@@ -300,9 +346,38 @@ class AuthController extends GetxController {
|
||||
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 ok = res.status == true && token != null && token.isNotEmpty;
|
||||
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);
|
||||
@@ -329,19 +404,40 @@ class AuthController extends GetxController {
|
||||
return true;
|
||||
}
|
||||
|
||||
_showBottomSheet(
|
||||
title: 'Login failed',
|
||||
message: (res.message != null && res.message!.trim().isNotEmpty)
|
||||
? res.message!
|
||||
: 'Incorrect phone number or PIN. Please try again.',
|
||||
);
|
||||
// ── 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');
|
||||
_showBottomSheet(
|
||||
title: 'Connection error',
|
||||
message: 'Could not reach the server. Check your internet and retry.',
|
||||
);
|
||||
lastPinFailure =
|
||||
'Could not reach the server. Check your connection and try again.';
|
||||
_showBottomSheet(title: 'Connection error', message: lastPinFailure!);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user