Files
doormile_milderapp/lib/providers/auth/auth_provider.dart
2026-08-11 13:16:33 +05:30

402 lines
16 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:miler/Models/login/login.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/miler_api.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AuthProvider {
Future<http.Response> login({
required String contactNo,
required String deviceType,
required int configId,
required String deviceId,
required String fcmToken,
int? pin,
String? pinRaw,
}) async {
// The legacy `jupiter.doormile.app/.../rider/login` path that used to sit
// behind a flag here is gone with the rest of the old backend.
return _loginNew(
contactNo: contactNo,
pin: pin,
pinRaw: pinRaw,
fcmToken: fcmToken,
);
}
/// NEW API: POST /miler/verify-pin { phone, pin, device_token }
/// -> { success, token, data:{ userid, displayname, phone, hubid,
/// availabilitystatus, rating } }
///
/// We store the bearer token, then return a synthetic http.Response whose body
/// is the LEGACY `{status, details:{...}}` shape so [loginParsed] persists the
/// same SharedPreferences keys it always has — no change to the auth UI flow.
///
/// `device_token` is REQUIRED for the rider to receive push at all: the backend
/// stores it on the miler profile at verify-pin time and
/// AssignMilerToBooking pushes "New Pickup Assigned" to exactly that token.
/// Omitting it leaves the profile's token empty and silently makes the app
/// poll-only.
Future<http.Response> _loginNew({
required String contactNo,
int? pin,
String? pinRaw,
String? fcmToken,
}) async {
final uri = Uri.parse(ApiConfig.url('/miler/verify-pin'));
// The backend bcrypt-compares the PIN as a STRING, so a leading zero is
// significant. Prefer the raw text the rider typed — round-tripping through
// int drops it ("0512" -> 512 -> "512") and fails a valid PIN.
final String? pinValue = (pinRaw != null && pinRaw.isNotEmpty)
? pinRaw
: pin?.toString();
final body = {
'phone': contactNo,
if (pinValue != null) 'pin': pinValue,
// Riders live in partition 1001. It defaults server-side, so omitting it
// appeared to work — but a miler row created without it can never log in,
// and sending it explicitly is the only way the app and the console agree
// about which partition a rider belongs to.
'configid': MilerApi.configId,
if (fcmToken != null && fcmToken.isNotEmpty) 'device_token': fcmToken,
};
debugPrint('[AUTH][LOGIN][NEW] URL: $uri');
debugPrint('[AUTH][LOGIN][NEW] Body: ${json.encode(body)}');
final res = await http.post(
uri,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: json.encode(body),
);
debugPrint('[AUTH][LOGIN][NEW] Status: ${res.statusCode}');
debugPrint('[AUTH][LOGIN][NEW] Response: ${res.body}');
Map<String, dynamic> decoded = <String, dynamic>{};
try {
if (res.body.isNotEmpty) {
decoded = json.decode(res.body) as Map<String, dynamic>;
}
} catch (_) {}
final bool ok =
decoded['success'] == true &&
res.statusCode >= 200 &&
res.statusCode < 300;
// REAL shape: { success, token, user:{ authname, contactno, email, userid,
// profile:{ userid, displayname, phone, hubid, applocationid,
// availabilitystatus, rating, ... } } } (the pasted doc was wrong).
final Map user = (decoded['user'] is Map)
? decoded['user'] as Map
: (decoded['data'] is Map
? decoded['data'] as Map
: <String, dynamic>{});
final Map profile = (user['profile'] is Map)
? user['profile'] as Map
: <String, dynamic>{};
// Prefer profile field, then top-level user field.
dynamic pick(String k) => profile[k] ?? user[k];
// Persist bearer token for all subsequent authenticated calls.
final String token = (decoded['token'] ?? '').toString();
if (token.isNotEmpty) await ApiConfig.setToken(token);
// displayname -> first/last name split (best effort).
final String displayName = (pick('displayname') ?? user['authname'] ?? '')
.toString()
.trim();
final int spaceIdx = displayName.indexOf(' ');
final String firstName = spaceIdx > 0
? displayName.substring(0, spaceIdx)
: displayName;
final String lastName = spaceIdx > 0
? displayName.substring(spaceIdx + 1).trim()
: '';
final String availability = (pick('availabilitystatus') ?? 'Offline')
.toString();
final int onduty =
(availability.toLowerCase() == 'offline' || availability.isEmpty)
? 0
: 1;
final userId = user['userid'] ?? profile['userid'] ?? 0;
final phone = user['contactno'] ?? profile['phone'] ?? contactNo;
// Map the new payload into the legacy `details` shape loginParsed reads.
final legacy = <String, dynamic>{
'status': ok,
'code': ok ? 200 : (decoded['code'] ?? 400),
'message': decoded['message']?.toString() ?? '',
'details': <String, dynamic>{
'userid': userId,
'riderid': userId,
'displayname': displayName,
'username': displayName,
'firstname': firstName,
'lastname': lastName,
'contactno': phone,
'email': user['email'] ?? '',
// hub/app-location scoping.
'hubid': profile['hubid'] ?? 0,
'locationid': profile['applocationid'] ?? profile['hubid'] ?? 0,
'applocationid': profile['applocationid'] ?? 0,
'rating': pick('rating') ?? 0,
'availabilitystatus': availability,
'onduty': onduty,
// Fields the new contract does not provide yet — safe defaults.
'shiftid': 0,
'logid': 0,
'partnerid': 0,
'configid': 0,
'tenantid': 0,
'pickupradius': 100,
'starttime': '',
'endtime': '',
},
};
// Also persist contactno directly (rider logs read it from prefs).
final prefs = await SharedPreferences.getInstance();
await prefs.setString('contactno', phone.toString());
return http.Response(
json.encode(legacy),
ok ? 200 : res.statusCode,
headers: {'content-type': 'application/json'},
);
}
/// NEW API: POST /miler/login { phone }
///
/// Account-existence precheck. 200 means the phone belongs to an active miler
/// account that already has a PIN on file, so the rider should go straight to
/// the MPIN screen — no OTP, no Create-MPIN (which would overwrite the PIN
/// they were given). 404 means the number isn't registered.
///
/// Returns true only for an existing, active miler account.
Future<bool> milerAccountExists(String contactNo) async {
try {
final uri = Uri.parse(ApiConfig.url('/miler/login'));
final res = await http
.post(
uri,
headers: const {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: json.encode({
'phone': contactNo,
'configid': MilerApi.configId,
}),
)
.timeout(const Duration(seconds: 15));
debugPrint(
'[AUTH][PRECHECK] $contactNo -> ${res.statusCode} ${res.body}',
);
if (res.statusCode < 200 || res.statusCode >= 300) return false;
final decoded = json.decode(res.body);
return decoded is Map && decoded['success'] == true;
} catch (e) {
debugPrint('[AUTH][PRECHECK] error: $e');
return false;
}
}
/// NEW API: PUT /miler/device-token { device_token }
///
/// verify-pin only registers the token at login time, but FCM rotates tokens
/// independently of the session. Without re-registering, the backend keeps
/// pushing to a dead token and the rider stops seeing new-assignment alerts
/// with no visible symptom. Call this whenever the token changes.
Future<bool> saveDeviceToken(String fcmToken) async {
if (fcmToken.isEmpty) return false;
try {
final uri = Uri.parse(ApiConfig.url('/miler/device-token'));
final res = await http
.put(
uri,
headers: await ApiConfig.authHeaders(),
body: json.encode({'device_token': fcmToken}),
)
.timeout(const Duration(seconds: 15));
debugPrint('[AUTH][DEVICE_TOKEN] status=${res.statusCode}');
return res.statusCode >= 200 && res.statusCode < 300;
} catch (e) {
debugPrint('[AUTH][DEVICE_TOKEN] error: $e');
return false;
}
}
// Convenience: send using a Login model body
Future<http.Response> loginWith(Login request) async {
final uri = Uri.parse(
'https://jupiter.doormile.app/live/api/v2/users/rider/login',
);
final body = request.toJson();
debugPrint('[AUTH][LOGIN] URL: ${uri.toString()}');
debugPrint('[AUTH][LOGIN] Body: ${json.encode(body)}');
final res = await http.post(
uri,
headers: {'Content-Type': 'application/json'},
body: json.encode(body),
);
debugPrint('[AUTH][LOGIN] Status: ${res.statusCode}');
debugPrint('[AUTH][LOGIN] Response: ${res.body}');
return res;
}
// Convenience: parsed response as Login model
Future<Login> loginParsed({
required String contactNo,
required String deviceType,
required int configId,
required String deviceId,
required String fcmToken,
int? pin,
String? pinRaw,
}) async {
final res = await login(
contactNo: contactNo,
deviceType: deviceType,
configId: configId,
deviceId: deviceId,
fcmToken: fcmToken,
pin: pin,
pinRaw: pinRaw,
);
final Map<String, dynamic> jsonMap = res.body.isNotEmpty
? json.decode(res.body) as Map<String, dynamic>
: <String, dynamic>{};
debugPrint('[AUTH] Raw Login JSON: $jsonMap');
if (jsonMap.containsKey('details')) {
final details = jsonMap['details'];
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('userid', details['userid'] ?? 0);
await prefs.setInt('userId', details['userid'] ?? 0);
await prefs.setInt('shiftid', details['shiftid'] ?? 0);
await prefs.setInt('shiftId', details['shiftid'] ?? 0);
await prefs.setInt('logid', details['logid'] ?? 0);
await prefs.setInt('logId', details['logid'] ?? 0);
await prefs.setInt('riderid', details['riderid'] ?? 0);
await prefs.setInt('partnerid', details['partnerid'] ?? 0);
await prefs.setInt('partnerId', details['partnerid'] ?? 0);
await prefs.setInt('configid', details['configid'] ?? 0);
await prefs.setInt('logseconds', details['logseconds'] ?? 0);
await prefs.setInt('locationid', details['locationid'] ?? 0);
await prefs.setInt('tenantid', details['tenantid'] ?? 0);
await prefs.setInt('applocationid', details['applocationid'] ?? 0);
final String fcm = (details['userfcmtoken'] ?? '').toString();
if (fcm.isNotEmpty) {
await prefs.setString('userfcmtoken', fcm);
}
// Persist rider name variants for downstream usage (e.g. rider logs)
final String firstName = (details['firstname'] ?? '').toString();
final String lastName = (details['lastname'] ?? '').toString();
final String apiUsername = (details['username'] ?? '').toString();
final String combinedName = ('$firstName $lastName').trim();
if (apiUsername.isNotEmpty) {
await prefs.setString('username', apiUsername);
} else if (combinedName.isNotEmpty) {
await prefs.setString('username', combinedName);
}
if (firstName.isNotEmpty) {
await prefs.setString('firstname', firstName);
}
if (lastName.isNotEmpty) {
await prefs.setString('lastname', lastName);
}
if (details['onduty'] != null) {
final int od = (details['onduty'] is num)
? (details['onduty'] as num).toInt()
: int.tryParse('${details['onduty']}') ?? 0;
await prefs.setInt('onduty', od);
}
// Persist rider payout config (per-kilometer fuel/rider charge) if provided
if (details.containsKey('fuelcharge')) {
final double fuelCharge =
double.tryParse('${details['fuelcharge']}') ?? 0.0;
await prefs.setDouble('fuelcharge', fuelCharge);
}
// Backward compatibility with older field names
if (details.containsKey('firstmilecharge')) {
final double firstMileCharge =
double.tryParse('${details['firstmilecharge']}') ?? 0.0;
await prefs.setDouble('firstmilecharge', firstMileCharge);
} else if (details.containsKey('firstmilecharges')) {
final double firstMileCharge =
double.tryParse('${details['firstmilecharges']}') ?? 0.0;
await prefs.setDouble('firstmilecharge', firstMileCharge);
}
// Save shift window for header display
if (details['starttime'] != null) {
await prefs.setString('starttime', details['starttime'].toString());
}
await prefs.setString('endtime', details['endtime'].toString());
// Save pickup radius for geofencing (default 100m if not provided)
if (details['pickupradius'] != null) {
final int radius = (details['pickupradius'] is num)
? (details['pickupradius'] as num).toInt()
: int.tryParse('${details['pickupradius']}') ?? 100;
await prefs.setInt('pickupradius', radius);
debugPrint('[AUTH] Saved pickupradius: $radius meters');
} else {
await prefs.setInt('pickupradius', 100); // Default
debugPrint('[AUTH] Saved default pickupradius: 100 meters');
}
debugPrint(
'[AUTH] SharedPrefs Saved: '
'userid=${details['userid']}, shiftid=${details['shiftid']}, '
'logid=${details['logid']}, riderid=${details['riderid']},'
'partnerid=${details['partnerid']}, configid=${details['configid']}',
);
// Rider log creation is deferred until the rider goes ON duty.
//
// NOTE: We intentionally do NOT auto-navigate from here anymore.
// Navigation after login / PIN verification is handled in the UI flows
// (e.g. MPIN screen) so that riders cannot reach the homepage before
// successfully entering a valid PIN.
}
return Login.fromJson(jsonMap);
}
Future<http.Response> updatePin({
required int userId,
required int pin,
}) async {
// ── There is no rider-facing set-PIN endpoint, and that is deliberate ──
//
// The only PIN-write route on the backend is `POST /miler/reset-pin`, and
// it requires an ADMIN token. It was once open, and reset-pin followed by
// verify-pin took over any rider account given nothing but a phone number.
// The app must not call it; rider PIN resets go through ops.
//
// So this stays a no-op success: the Create-MPIN screen's flow completes
// and the PIN the account was issued with remains the one that works.
// Making it fail instead would strand a rider on a screen with no way
// forward, which is worse and no more honest.
ApiConfig.logGap(
'updatePin',
'No rider-facing set-PIN route; reset-pin is admin-only by design.',
);
return http.Response(
json.encode(ApiConfig.okEnvelope('pin is managed by ops')),
200,
headers: {'content-type': 'application/json'},
);
}
}