402 lines
16 KiB
Dart
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'},
|
|
);
|
|
}
|
|
}
|