first commit
This commit is contained in:
232
lib/providers/Riderlog/riderlog_provider.dart
Normal file
232
lib/providers/Riderlog/riderlog_provider.dart
Normal file
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// DUTY, BREAKS AND THE LOCATION HEARTBEAT
|
||||
///
|
||||
/// ── What was deleted here ──
|
||||
///
|
||||
/// A `_buildSslBypassClient()` that returned an `IOClient` whose
|
||||
/// `badCertificateCallback` returned `true` for every certificate, pinned a
|
||||
/// hardcoded IP for `queue.workolik.com`, and hand-rolled its own
|
||||
/// `SecureSocket` upgrade. It existed to work around a carrier's broken DNS on
|
||||
/// a backend that is no longer deployed — and it meant every request it carried
|
||||
/// could be read and rewritten by anything on the path, including a rider's GPS
|
||||
/// track and his duty state.
|
||||
///
|
||||
/// It is gone along with the rest of the legacy path. `api.doormile.com` is
|
||||
/// reached over ordinary, verified TLS.
|
||||
///
|
||||
/// ── The class names survive their implementations ──
|
||||
///
|
||||
/// `RiderLogController` drives duty through create/update/get "rider log"
|
||||
/// calls, which is the legacy shape. Rather than rewrite that controller, these
|
||||
/// four classes keep their names and map the intent onto the real endpoints:
|
||||
/// the first log of a session starts duty, later ones are location heartbeats,
|
||||
/// and an off-duty payload ends it.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
String _s(dynamic v) => v == null ? '' : v.toString();
|
||||
|
||||
double? _d(dynamic v) => double.tryParse(_s(v));
|
||||
|
||||
/// Legacy `{status, details}` for the duty-state readers.
|
||||
Map<String, dynamic> _dutyEnvelope(Map data) {
|
||||
final bool on = data['onduty'] == true;
|
||||
final dutylogid = data['dutylogid'] ?? 0;
|
||||
return {
|
||||
'status': true,
|
||||
'code': 200,
|
||||
'details': {
|
||||
'logid': dutylogid,
|
||||
'onduty': on ? 1 : 0,
|
||||
'login': data['loginat'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _startDuty(Map data) async {
|
||||
final res = await MilerApi.startDuty(
|
||||
lat: _d(data['latitude']),
|
||||
lon: _d(data['longitude']),
|
||||
);
|
||||
if (res.ok) {
|
||||
final id = int.tryParse('${res.map['dutylogid'] ?? 0}') ?? 0;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('logid', id);
|
||||
await prefs.setInt('logId', id);
|
||||
await prefs.setInt('dutylogid', id);
|
||||
return {
|
||||
'status': true,
|
||||
'code': 200,
|
||||
'details': {
|
||||
'logid': id,
|
||||
'login': res.map['loginat'],
|
||||
'onduty': 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
// "Already on duty" is a 400 and is not a failure — it is the app and the
|
||||
// server disagreeing about state, which the current duty answers.
|
||||
if (res.status == 400) return _currentDuty();
|
||||
return {'status': false, 'code': res.status};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _currentDuty() async {
|
||||
final res = await MilerApi.dutyCurrent();
|
||||
if (!res.ok) return {'status': false, 'code': res.status};
|
||||
|
||||
final on = res.map['onduty'] == true;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('onduty', on ? 1 : 0);
|
||||
if (on) {
|
||||
final id = int.tryParse('${res.map['dutylogid'] ?? 0}') ?? 0;
|
||||
await prefs.setInt('logid', id);
|
||||
await prefs.setInt('logId', id);
|
||||
}
|
||||
return _dutyEnvelope(res.map);
|
||||
}
|
||||
|
||||
/// The GPS heartbeat, which is two writes with different lifetimes:
|
||||
///
|
||||
/// • `PUT /miler/location` — Redis, and the index dispatch searches. This is
|
||||
/// the one that decides whether the rider is findable.
|
||||
/// • `POST /miler/logs` — the telemetry trail, for reconstructing a shift
|
||||
/// afterwards.
|
||||
///
|
||||
/// Both are best-effort and neither blocks the rider. The location write is
|
||||
/// reported back because duty state depends on it; the log is fire-and-forget.
|
||||
Future<Map<String, dynamic>?> _heartbeat(Map data) async {
|
||||
final lat = _d(data['latitude']);
|
||||
final lon = _d(data['longitude']);
|
||||
if (lat == null || lon == null) return ApiConfig.okEnvelope('no fix');
|
||||
|
||||
final res = await MilerApi.pushLocation(
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
pincode: _s(data['pincode']).isEmpty ? null : _s(data['pincode']),
|
||||
speed: _d(data['speed']),
|
||||
heading: _d(data['heading']),
|
||||
);
|
||||
|
||||
// Deliberately not awaited into the result: a telemetry failure must never
|
||||
// look like a location failure, because the caller treats the latter as duty
|
||||
// state going wrong.
|
||||
unawaited(
|
||||
MilerApi.postLog(
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
speed: _d(data['speed']),
|
||||
heading: _d(data['heading']),
|
||||
accuracy: _d(data['accuracy']),
|
||||
status: _s(data['status']).isEmpty ? null : _s(data['status']),
|
||||
orderId: data['orderid'],
|
||||
battery: int.tryParse(_s(data['battery'])),
|
||||
isCharging: data['is_charging'] == true,
|
||||
connection: _s(data['connection']).isEmpty
|
||||
? null
|
||||
: _s(data['connection']),
|
||||
isBackground: data['is_background'] == true,
|
||||
),
|
||||
);
|
||||
|
||||
return res.ok ? ApiConfig.okEnvelope() : {'status': false, 'code': res.status};
|
||||
}
|
||||
|
||||
/// True when the payload means "go off duty".
|
||||
bool _isOffDutyIntent(Map data) {
|
||||
final status = _s(data['status']).toLowerCase();
|
||||
final onduty = data['onduty'];
|
||||
final bool ondutyZero = onduty == 0 || _s(onduty) == '0';
|
||||
return status.contains('offline') ||
|
||||
status.contains('logout') ||
|
||||
data.containsKey('logout') ||
|
||||
(data.containsKey('onduty') && ondutyZero);
|
||||
}
|
||||
|
||||
class CreateRiderLogProvider {
|
||||
/// The first call of a session starts duty; every later one is a heartbeat.
|
||||
Future<Map<String, dynamic>?> createRiderLog(
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final int existing = prefs.getInt('dutylogid') ?? 0;
|
||||
if (existing <= 0) return _startDuty(data);
|
||||
return _heartbeat(data);
|
||||
}
|
||||
}
|
||||
|
||||
class UpdateRiderLogProvider {
|
||||
Future<Map<String, dynamic>?> updateRiderLog(
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
if (_isOffDutyIntent(data)) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('dutylogid', 0);
|
||||
final res = await MilerApi.endDuty();
|
||||
if (res.ok) await prefs.setInt('onduty', 0);
|
||||
return res.ok
|
||||
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
|
||||
: {'status': false, 'code': res.status};
|
||||
}
|
||||
|
||||
final onduty = data['onduty'];
|
||||
if (onduty == 1 || _s(onduty) == '1') {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if ((prefs.getInt('dutylogid') ?? 0) <= 0) return _startDuty(data);
|
||||
}
|
||||
return _heartbeat(data);
|
||||
}
|
||||
}
|
||||
|
||||
class GetRiderLogProvider {
|
||||
Future<Map<String, dynamic>?> getRiderLog() => _currentDuty();
|
||||
|
||||
/// There is no rider-count endpoint in the contract, and no screen that needs
|
||||
/// one — the dashboard figures come from bookings and earnings. Kept as a
|
||||
/// zero so the one legacy caller does not have to branch.
|
||||
Future<Map<String, dynamic>?> getRiderCount() async {
|
||||
ApiConfig.logGap('getRiderCount', 'No count endpoint in v1.');
|
||||
return {'status': true, 'count': 0};
|
||||
}
|
||||
}
|
||||
|
||||
class BreakRiderLogProvider {
|
||||
Future<Map<String, dynamic>?> createBreakRiderLog(
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
final bt = _s(data['breaktype'] ?? data['reason'] ?? 'Personal');
|
||||
final res = await MilerApi.startBreak(bt);
|
||||
if (res.ok) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(
|
||||
'breaklogid',
|
||||
int.tryParse('${res.map['breaklogid'] ?? 0}') ?? 0,
|
||||
);
|
||||
// The availability enum's break value is `Break`, not `On_Break` — the
|
||||
// obvious guess is the wrong one and is silently rejected.
|
||||
unawaited(MilerApi.setAvailability('Break'));
|
||||
return ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true});
|
||||
}
|
||||
return {'status': false, 'code': res.status};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> updateBreakRiderLog() async {
|
||||
final res = await MilerApi.endBreak();
|
||||
if (res.ok) unawaited(MilerApi.setAvailability('Available'));
|
||||
return res.ok
|
||||
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
|
||||
: {'status': false, 'code': res.status};
|
||||
}
|
||||
}
|
||||
|
||||
/// Local `unawaited`, so a fire-and-forget call reads as deliberate rather than
|
||||
/// as a missing `await`.
|
||||
void unawaited(Future<void> future) {
|
||||
future.catchError((Object e) {
|
||||
debugPrint('[RIDERLOG] background call failed: $e');
|
||||
});
|
||||
}
|
||||
401
lib/providers/auth/auth_provider.dart
Normal file
401
lib/providers/auth/auth_provider.dart
Normal file
@@ -0,0 +1,401 @@
|
||||
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'},
|
||||
);
|
||||
}
|
||||
}
|
||||
431
lib/providers/notifications/notificationservce.dart
Normal file
431
lib/providers/notifications/notificationservce.dart
Normal file
@@ -0,0 +1,431 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:miler/helpers/http_overrides.dart';
|
||||
import 'package:miler/providers/auth/auth_provider.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
|
||||
// Top-level background handler required by Firebase Messaging
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
HttpOverrides.global = MyHttpOverrides();
|
||||
try {
|
||||
await Firebase.initializeApp();
|
||||
} catch (_) {}
|
||||
|
||||
await NotificationServce.display(message);
|
||||
}
|
||||
|
||||
class NotificationServce {
|
||||
static final FirebaseMessaging _firebaseMessaging =
|
||||
FirebaseMessaging.instance;
|
||||
static final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
static final AudioPlayer _player = AudioPlayer();
|
||||
static String _channelId = 'Miler';
|
||||
static bool _isPlaying = false;
|
||||
|
||||
static const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
||||
'Miler',
|
||||
'Miler Notification',
|
||||
description: 'Channel for Miler notifications',
|
||||
importance: Importance.max,
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
showBadge: true,
|
||||
);
|
||||
|
||||
static Future<void> initialize(BuildContext context) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final alreadyInit = prefs.getBool('notifications_init_done') ?? false;
|
||||
if (alreadyInit) {
|
||||
return;
|
||||
}
|
||||
|
||||
await FirebaseMessaging.instance.requestPermission(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
|
||||
final existing = (prefs.getString('order_alert_sound') ?? '').trim();
|
||||
if (existing.isEmpty) {
|
||||
await prefs.setString('order_alert_sound', 'assets/audio/alert-1.mp3');
|
||||
}
|
||||
await prefs.setBool('notifications_init_done', true);
|
||||
} catch (_) {}
|
||||
|
||||
await _notificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.createNotificationChannel(channel);
|
||||
|
||||
await _applyChannelSoundFromPrefs();
|
||||
|
||||
const InitializationSettings initializationSettings =
|
||||
InitializationSettings(
|
||||
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
iOS: DarwinInitializationSettings(
|
||||
requestSoundPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestAlertPermission: true,
|
||||
defaultPresentSound: true,
|
||||
defaultPresentBadge: true,
|
||||
defaultPresentBanner: true,
|
||||
defaultPresentAlert: true,
|
||||
defaultPresentList: true,
|
||||
),
|
||||
);
|
||||
|
||||
await _notificationsPlugin.initialize(
|
||||
initializationSettings,
|
||||
onDidReceiveNotificationResponse:
|
||||
(NotificationResponse response) async {},
|
||||
);
|
||||
|
||||
RemoteMessage? initialMessage = await _firebaseMessaging
|
||||
.getInitialMessage();
|
||||
if (initialMessage != null) {
|
||||
await _handleInitialMessage(initialMessage);
|
||||
}
|
||||
|
||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) async {
|
||||
await _handleMessage(message);
|
||||
});
|
||||
|
||||
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) async {
|
||||
await _handleMessageOpenedApp(message);
|
||||
});
|
||||
|
||||
// FCM rotates tokens independently of our session. The cached token in
|
||||
// prefs is what login registers with the backend, so without this listener
|
||||
// a rotation silently stops all new-assignment pushes until the next
|
||||
// reinstall. Re-cache and re-register on every rotation.
|
||||
FirebaseMessaging.instance.onTokenRefresh.listen((String token) async {
|
||||
if (token.isEmpty) return;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('fcmToken', token);
|
||||
await AuthProvider().saveDeviceToken(token);
|
||||
} catch (e) {
|
||||
debugPrint('[FCM] token refresh handling failed: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ Removed duplicated background handler (this was breaking your notifications)
|
||||
|
||||
static Future<void> _handleInitialMessage(RemoteMessage message) async {
|
||||
if (message.notification != null) {
|
||||
await display(message);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _handleMessage(RemoteMessage message) async {
|
||||
if (message.notification != null) {
|
||||
await display(message);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _handleMessageOpenedApp(RemoteMessage message) async {}
|
||||
|
||||
static Future<void> _applyChannelSoundFromPrefs() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
String sel = (prefs.getString('order_alert_sound') ?? '').trim();
|
||||
if (sel.isEmpty) {
|
||||
sel = 'assets/audio/alert-1.mp3';
|
||||
}
|
||||
|
||||
final fileName = sel.split('/').last;
|
||||
final base = fileName.split('.').first;
|
||||
final rawName = base.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_');
|
||||
|
||||
final androidImpl = _notificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (androidImpl != null) {
|
||||
_channelId = 'Miler_$rawName';
|
||||
|
||||
final custom = AndroidNotificationChannel(
|
||||
_channelId,
|
||||
'Miler Notification',
|
||||
description: 'Channel for Miler notifications',
|
||||
importance: Importance.max,
|
||||
playSound: true,
|
||||
sound: RawResourceAndroidNotificationSound(rawName),
|
||||
enableVibration: true,
|
||||
showBadge: true,
|
||||
);
|
||||
|
||||
await androidImpl.createNotificationChannel(custom);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<String?> _downloadAndSaveImage(
|
||||
String imageUrl,
|
||||
String fileName,
|
||||
) async {
|
||||
try {
|
||||
final directory = await getTemporaryDirectory();
|
||||
final filePath = '${directory.path}/$fileName';
|
||||
final response = await http.get(Uri.parse(imageUrl));
|
||||
if (response.statusCode == 200) {
|
||||
final file = File(filePath);
|
||||
await file.writeAsBytes(response.bodyBytes);
|
||||
return filePath;
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String? _extractImageUrl(RemoteMessage message) {
|
||||
String? imageUrl = message.data['image'] as String?;
|
||||
imageUrl ??= message.notification?.android?.imageUrl;
|
||||
imageUrl ??= message.notification?.apple?.imageUrl;
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
static Future<void> _playSelectedSound({int times = 1}) async {
|
||||
try {
|
||||
if (_isPlaying) return;
|
||||
|
||||
_isPlaying = true;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
String selected = (prefs.getString('order_alert_sound') ?? '').trim();
|
||||
if (selected.isEmpty) {
|
||||
selected = 'assets/audio/alert-1.mp3';
|
||||
}
|
||||
|
||||
final rel = selected.startsWith('assets/')
|
||||
? selected.replaceFirst('assets/', '')
|
||||
: selected;
|
||||
|
||||
await _player.stop();
|
||||
await _player.setReleaseMode(ReleaseMode.stop);
|
||||
|
||||
for (int i = 0; i < times; i++) {
|
||||
await _player.play(AssetSource(rel));
|
||||
try {
|
||||
await _player.onPlayerComplete.first;
|
||||
} catch (_) {}
|
||||
if (i < times - 1) {
|
||||
await Future.delayed(const Duration(milliseconds: 120));
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
} finally {
|
||||
_isPlaying = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight local notification helper for in-app events (no FCM message).
|
||||
static Future<void> showLocalNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
bool playSound = true,
|
||||
String? payload,
|
||||
}) async {
|
||||
try {
|
||||
final id = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
final notificationDetails = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_channelId,
|
||||
'Miler Notification',
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
playSound: playSound,
|
||||
enableVibration: true,
|
||||
channelShowBadge: true,
|
||||
ongoing: false,
|
||||
autoCancel: true,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: playSound,
|
||||
presentList: true,
|
||||
presentBanner: true,
|
||||
),
|
||||
);
|
||||
|
||||
await _notificationsPlugin.show(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
notificationDetails,
|
||||
payload: payload,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> display(RemoteMessage message) async {
|
||||
final id = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final payload = jsonEncode({'id': id.toString(), 'data': message.data});
|
||||
|
||||
final appState = WidgetsBinding.instance.lifecycleState;
|
||||
final bool isForeground = appState == AppLifecycleState.resumed;
|
||||
|
||||
if (isForeground) {
|
||||
await _playSelectedSound(times: 5);
|
||||
}
|
||||
|
||||
NotificationDetails notificationDetails;
|
||||
|
||||
final imageUrl = _extractImageUrl(message);
|
||||
String? persistedImageUrl = imageUrl;
|
||||
String? persistedImagePath;
|
||||
|
||||
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||
final imagePath = await _downloadAndSaveImage(
|
||||
imageUrl,
|
||||
'notification_image.jpg',
|
||||
);
|
||||
persistedImagePath = imagePath;
|
||||
|
||||
notificationDetails = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_channelId,
|
||||
'Miler Notification',
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
playSound: !isForeground,
|
||||
enableVibration: true,
|
||||
fullScreenIntent: true,
|
||||
channelShowBadge: true,
|
||||
ongoing: false,
|
||||
autoCancel: true,
|
||||
styleInformation: imagePath != null
|
||||
? BigPictureStyleInformation(FilePathAndroidBitmap(imagePath))
|
||||
: const DefaultStyleInformation(true, true),
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
presentList: true,
|
||||
presentBanner: true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
notificationDetails = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_channelId,
|
||||
'Miler Notification',
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
playSound: !isForeground,
|
||||
enableVibration: true,
|
||||
fullScreenIntent: true,
|
||||
channelShowBadge: true,
|
||||
ongoing: false,
|
||||
autoCancel: true,
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
presentList: true,
|
||||
presentBanner: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (isForeground) {
|
||||
final ctx = Get.context;
|
||||
if (ctx != null) {
|
||||
final title =
|
||||
message.notification?.title ?? message.data['title'] ?? 'Miler';
|
||||
final body = message.notification?.body ?? message.data['body'] ?? '';
|
||||
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
if (body.isNotEmpty) Text(body),
|
||||
],
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await _notificationsPlugin.show(
|
||||
id,
|
||||
message.notification?.title ?? message.data['title'] ?? 'Miler',
|
||||
message.notification?.body ?? message.data['body'] ?? 'Notification',
|
||||
notificationDetails,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final nowIso = DateTime.now().toIso8601String();
|
||||
|
||||
final title =
|
||||
message.notification?.title ?? message.data['title'] ?? 'Miler';
|
||||
final body = message.notification?.body ?? message.data['body'] ?? '';
|
||||
|
||||
final entry = {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'body': body,
|
||||
'time': nowIso,
|
||||
'data': message.data,
|
||||
if (persistedImageUrl != null) 'imageUrl': persistedImageUrl,
|
||||
if (persistedImagePath != null) 'imagePath': persistedImagePath,
|
||||
};
|
||||
|
||||
final existingRaw = prefs.getString('notifications_log');
|
||||
List<dynamic> list = [];
|
||||
|
||||
if (existingRaw != null && existingRaw.isNotEmpty) {
|
||||
try {
|
||||
list = jsonDecode(existingRaw) as List<dynamic>;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
list.insert(0, entry);
|
||||
|
||||
if (list.length > 100) list = list.sublist(0, 100);
|
||||
|
||||
await prefs.setString('notifications_log', jsonEncode(list));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
59
lib/providers/pickup/pickup_provider.dart
Normal file
59
lib/providers/pickup/pickup_provider.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
|
||||
/// Reads the rider's bookings.
|
||||
///
|
||||
/// ── What this used to be ──
|
||||
///
|
||||
/// Three near-identical methods — `getPickupQueues`, `getCurrentPickups`,
|
||||
/// `getPickupQueuesPicked` — each with its own legacy URL, its own
|
||||
/// `fromdate`/`todate`/`userid` query and its own hand-rolled envelope
|
||||
/// unwrapping, and each opening with `if (ApiConfig.useNewApi) return
|
||||
/// _getBookingsNew(...)`. All three legacy paths hit different endpoints on a
|
||||
/// backend that is no longer deployed, and all three new paths were the same
|
||||
/// call. What is left is that one call.
|
||||
///
|
||||
/// The three names survive because three call sites read differently — Home
|
||||
/// wants the queue, Bookings wants the accepted work — even though the request
|
||||
/// is identical and the filtering happens client-side. Collapsing them into one
|
||||
/// method is a separate change to the screens, not to this file.
|
||||
class PickupProvider {
|
||||
/// `GET /miler/bookings` → the list, mapped into the legacy stop shape the
|
||||
/// cards read.
|
||||
///
|
||||
/// The mapping lives in [ApiConfig.pickupFromBooking] rather than here: it is
|
||||
/// the one place that knows how a v1 booking becomes a stop, and the same
|
||||
/// translation is needed by anything else that receives a booking.
|
||||
Future<List<dynamic>> _bookings({String? status}) async {
|
||||
final res = await MilerApi.bookings(status: status);
|
||||
if (!res.ok) {
|
||||
throw Exception('Failed (${res.status})${res.message.isEmpty ? '' : ': ${res.message}'}');
|
||||
}
|
||||
|
||||
final mapped = ApiConfig.pickupsFromBookings(res.list);
|
||||
debugPrint('[BOOKINGS] ${res.list.length} rows → ${mapped.length} stops');
|
||||
if (mapped.isEmpty && res.list.isNotEmpty) {
|
||||
// The rows arrived and none of them survived translation, which is a
|
||||
// field-name mismatch rather than an empty day. Worth saying out loud —
|
||||
// it is the difference between "no work" and "the adapter is wrong".
|
||||
ApiConfig.logGap(
|
||||
'pickupFromBooking',
|
||||
'${res.list.length} bookings returned but none mapped — check the '
|
||||
'field names against a real payload.',
|
||||
);
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/// Everything the hub has put in front of this rider today.
|
||||
Future<List<dynamic>> getPickupQueues({String? orderstatus}) =>
|
||||
_bookings(status: orderstatus);
|
||||
|
||||
/// The stops he is working now.
|
||||
Future<List<dynamic>> getCurrentPickups() => _bookings();
|
||||
|
||||
/// The stops he has accepted.
|
||||
Future<List<dynamic>> getPickupQueuesPicked() => _bookings();
|
||||
}
|
||||
299
lib/providers/pickuplog/pickuplog_provider.dart
Normal file
299
lib/providers/pickuplog/pickuplog_provider.dart
Normal file
@@ -0,0 +1,299 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/data/assignment_lookup.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE PER-STOP WRITE PATH
|
||||
///
|
||||
/// Everything the rider does at a door goes through here.
|
||||
///
|
||||
/// ── What changed ──
|
||||
///
|
||||
/// This file used to carry two complete implementations of every call: a
|
||||
/// legacy one that spoke to `queue.workolik.com` through a hand-built
|
||||
/// `IOClient` — one that disabled certificate checking, pinned a hardcoded IP
|
||||
/// and did its own TLS upgrade to work around a carrier's broken DNS — and a
|
||||
/// v1 one behind `if (ApiConfig.useNewApi)`. The legacy backend is gone, and
|
||||
/// with it the reason to ship a client that trusts any certificate presented to
|
||||
/// it. Both are deleted.
|
||||
///
|
||||
/// ── The four calls that were missing ──
|
||||
///
|
||||
/// The v1 pickup flow is four ordered steps, and only two of them were ever
|
||||
/// sent. `parcel` and `payment` were collected from the rider at the door and
|
||||
/// then dropped on the floor:
|
||||
///
|
||||
/// 1. `reached` ✅ was wired
|
||||
/// 2. `parcel` ❌ the weight and count he typed went nowhere
|
||||
/// 3. `payment` ❌ the cash he took went nowhere
|
||||
/// 4. `pickup-complete` ✅ was wired
|
||||
///
|
||||
/// Step 2 is the one that costs money: `pickup-complete` recomputes chargeable
|
||||
/// weight from those dimensions, so a booking completed without them bills on
|
||||
/// the customer's estimate rather than on what the rider actually weighed. And
|
||||
/// step 3 is the rider's own accountability record for cash he is carrying.
|
||||
///
|
||||
/// `skip` and `cancel` were worse than missing — they returned a *fabricated
|
||||
/// success*, so the app told the rider his skip had registered while nothing
|
||||
/// left the phone. Both endpoints exist and are now called.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Per-stop GPS breadcrumbs, tied to a consignment.
|
||||
class CreatePickupLogProvider {
|
||||
/// Was a no-op returning a synthetic success, on the grounds that "there is
|
||||
/// no audit-log endpoint in v1". There is: `POST /miler/consignments/logs`,
|
||||
/// which takes a JSON **array** and wants its numbers as strings.
|
||||
///
|
||||
/// Best-effort by design. Telemetry is Redis-backed and is explicitly not the
|
||||
/// system of record — losing a breadcrumb costs a line on a map, and blocking
|
||||
/// the rider to retry one would cost him a stop.
|
||||
Future<Map<String, dynamic>?> createPickupLog(
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
final consignmentId = data['consignmentid'] ?? data['orderheaderid'] ??
|
||||
data['pickupid'];
|
||||
final lat = double.tryParse('${data['latitude'] ?? data['riderslat'] ?? ''}');
|
||||
final lon =
|
||||
double.tryParse('${data['longitude'] ?? data['riderslon'] ?? ''}');
|
||||
|
||||
if (consignmentId == null || lat == null || lon == null) {
|
||||
// Not enough to place the breadcrumb. Silently skipping is right — this
|
||||
// fires on a timer and a missing fix is normal, not an error.
|
||||
return ApiConfig.okEnvelope();
|
||||
}
|
||||
|
||||
final res = await MilerApi.postConsignmentLogs([
|
||||
ConsignmentLogEntry(
|
||||
consignmentId: consignmentId,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
status: (data['status'] ?? data['orderstatus'])?.toString(),
|
||||
speed: double.tryParse('${data['speed'] ?? ''}'),
|
||||
heading: double.tryParse('${data['heading'] ?? ''}'),
|
||||
battery: int.tryParse('${data['battery'] ?? ''}'),
|
||||
remarks: (data['remarks'] ?? '').toString(),
|
||||
isBackground: data['is_background'] == true,
|
||||
),
|
||||
]);
|
||||
return res.ok ? ApiConfig.okEnvelope() : {'status': false};
|
||||
}
|
||||
}
|
||||
|
||||
class UpdatePickupProvider {
|
||||
/// Maps a legacy `updatepickup` payload onto the v1 route its `orderstatus`
|
||||
/// means.
|
||||
///
|
||||
/// The legacy payload shape is kept because ~8 call sites in
|
||||
/// `PickupsController` build it, and rewriting those is a separate change to
|
||||
/// a file this one has no business touching. What matters is that every
|
||||
/// branch now ends in a real request or an honest failure — there are no
|
||||
/// invented successes left.
|
||||
Future<Map<String, dynamic>?> updatePickup(Map<String, dynamic> data) async {
|
||||
final String status = (data['orderstatus'] ?? '').toString();
|
||||
final id = data['pickupid'] ?? data['orderheaderid'];
|
||||
final String notes = (data['notes'] ?? '').toString();
|
||||
|
||||
final double? lat = double.tryParse(
|
||||
'${data['riderslat'] ?? data['pickuplat'] ?? ''}',
|
||||
);
|
||||
final double? lon = double.tryParse(
|
||||
'${data['riderslon'] ?? data['pickuplong'] ?? ''}',
|
||||
);
|
||||
|
||||
Map<String, dynamic> envelope(ApiResult r) => r.ok
|
||||
? ApiConfig.toLegacyEnvelope(r.raw ?? {'success': true})
|
||||
: {'status': false, 'code': r.status, 'message': r.message};
|
||||
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
case 'rejected':
|
||||
// Keyed on bookingassignmentid, not bookingid — different sequences.
|
||||
// Sending the booking id makes the backend answer 404 while the rider
|
||||
// is shown success and the booking is never actually accepted.
|
||||
final assignmentId = await AssignmentLookup.idForBooking(id);
|
||||
if (assignmentId == null) {
|
||||
ApiConfig.logGap(
|
||||
'update:$status',
|
||||
'No assignment row for booking $id — cannot $status.',
|
||||
);
|
||||
return {
|
||||
'status': false,
|
||||
'message': 'This stop is no longer available.',
|
||||
};
|
||||
}
|
||||
AssignmentLookup.invalidate();
|
||||
final r = status == 'accepted'
|
||||
? await MilerApi.acceptAssignment(assignmentId)
|
||||
: await MilerApi.rejectAssignment(
|
||||
assignmentId,
|
||||
reason: notes.isEmpty ? 'Declined by rider' : notes,
|
||||
);
|
||||
return envelope(r);
|
||||
|
||||
case 'arrived':
|
||||
return envelope(await MilerApi.reached(id, lat: lat, lon: lon));
|
||||
|
||||
case 'Picked up':
|
||||
case 'picked':
|
||||
return envelope(await MilerApi.pickupComplete(id, lat: lat, lon: lon));
|
||||
|
||||
case 'delivered':
|
||||
case 'Delivered':
|
||||
return envelope(
|
||||
await MilerApi.deliver(
|
||||
id,
|
||||
deliveredToName: (data['pickupcustomer'] ?? '').toString(),
|
||||
// Only sent when there is one. The tenant flag decides whether it
|
||||
// is required, and an empty string would fail a tenant that has it
|
||||
// on while being pointless for one that does not.
|
||||
otp: (data['otp'] ?? '').toString(),
|
||||
photoUrl: (data['dropimage'] ?? '').toString(),
|
||||
receiverSignatureUrl: (data['signature'] ?? '').toString(),
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
),
|
||||
);
|
||||
|
||||
case 'skipped':
|
||||
// Was a fabricated success — `okEnvelope('skip not supported by
|
||||
// backend')` — so the rider watched a stop move to "skipped" while
|
||||
// nothing left the phone and the hub never learned.
|
||||
return envelope(
|
||||
await MilerApi.skipConsignment(
|
||||
id,
|
||||
reason: notes.isEmpty ? 'Skipped by rider' : notes,
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
),
|
||||
);
|
||||
|
||||
case 'cancelled':
|
||||
// Also previously fabricated. Refused server-side once the stop is
|
||||
// picked up, which is correct and now surfaces as a real failure.
|
||||
return envelope(
|
||||
await MilerApi.cancelBooking(
|
||||
id,
|
||||
reason: notes.isEmpty ? 'Cancelled by rider' : notes,
|
||||
),
|
||||
);
|
||||
|
||||
case 'active':
|
||||
// `Pickup_Scheduled` is system-set; there is no rider endpoint for it.
|
||||
// Nudging availability is the closest true statement the app can make.
|
||||
return envelope(await MilerApi.setAvailability('On_Pickup'));
|
||||
|
||||
default:
|
||||
ApiConfig.logGap('update:unknown', 'Unmapped orderstatus="$status".');
|
||||
return ApiConfig.okEnvelope();
|
||||
}
|
||||
}
|
||||
|
||||
/// **Step 2** — what the rider actually took, measured at the door.
|
||||
///
|
||||
/// Reads the verification screen's payload directly rather than taking a
|
||||
/// dozen arguments, because that map is already the record of the door and
|
||||
/// splitting it would give two places to keep in step.
|
||||
///
|
||||
/// Dimensions are not collected by the app today — the verify screen asks for
|
||||
/// total weight and a count, not L×W×H per parcel — so they go as zero and
|
||||
/// the backend bills on weight alone. Collecting them is a UI change waiting
|
||||
/// on a decision about how much to ask a rider for at a doorstep.
|
||||
Future<Map<String, dynamic>?> submitParcels(
|
||||
Object bookingId,
|
||||
Map<String, dynamic> verification,
|
||||
) async {
|
||||
final pickup = verification['pickup'];
|
||||
if (pickup is! Map) return ApiConfig.okEnvelope('no pickup leg');
|
||||
|
||||
final int count = int.tryParse('${pickup['collected'] ?? 0}') ?? 0;
|
||||
final double totalWeight =
|
||||
double.tryParse('${pickup['weight'] ?? ''}') ?? 0;
|
||||
|
||||
if (count <= 0 || totalWeight <= 0) {
|
||||
ApiConfig.logGap(
|
||||
'submitParcels',
|
||||
'booking $bookingId: count=$count weight=$totalWeight — nothing to send.',
|
||||
);
|
||||
return {'status': false, 'message': 'Parcel details are incomplete.'};
|
||||
}
|
||||
|
||||
// The rider weighs the consignment, not each box. Splitting the total
|
||||
// evenly is the only honest distribution available from what he was asked,
|
||||
// and it keeps the chargeable total correct even though the per-parcel
|
||||
// figures are an even split rather than a measurement.
|
||||
final double each = totalWeight / count;
|
||||
final parcels = [
|
||||
for (var i = 0; i < count; i++) ParcelEntry(weight: each),
|
||||
];
|
||||
|
||||
final res = await MilerApi.submitParcels(bookingId, parcels);
|
||||
debugPrint(
|
||||
'[PARCEL] booking $bookingId: $count parcel(s), ${totalWeight}kg '
|
||||
'→ ${res.ok ? 'ok' : 'FAILED ${res.status} ${res.message}'}',
|
||||
);
|
||||
return res.ok
|
||||
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
|
||||
: {'status': false, 'code': res.status, 'message': res.message};
|
||||
}
|
||||
|
||||
/// **Step 3** — the money, from the collect-payment screen's payload.
|
||||
///
|
||||
/// Returns a success envelope without calling anything when nothing was
|
||||
/// collected: a prepaid stop has no payment to record, and posting a zero
|
||||
/// would be rejected anyway (`amount` must be > 0).
|
||||
Future<Map<String, dynamic>?> submitPayment(
|
||||
Object bookingId,
|
||||
Map<String, dynamic> payment,
|
||||
) async {
|
||||
if (payment['paid'] != true) return ApiConfig.okEnvelope('not collected');
|
||||
|
||||
final double amount =
|
||||
double.tryParse('${payment['amountCollected'] ?? 0}') ?? 0;
|
||||
if (amount <= 0) return ApiConfig.okEnvelope('nothing collected');
|
||||
|
||||
// The screen's own wire names are lowercase; the contract's enum is not.
|
||||
final String mode = switch ('${payment['method']}'.toLowerCase()) {
|
||||
'upi' => 'UPI',
|
||||
'card' => 'Card',
|
||||
'wallet' => 'Wallet',
|
||||
_ => 'Cash',
|
||||
};
|
||||
|
||||
final res = await MilerApi.submitPayment(
|
||||
bookingId,
|
||||
amount: amount,
|
||||
paymentMode: mode,
|
||||
transactionRef: (payment['reference'] ?? '').toString(),
|
||||
);
|
||||
debugPrint(
|
||||
'[PAYMENT] booking $bookingId: $mode ₹$amount '
|
||||
'→ ${res.ok ? 'ok' : 'FAILED ${res.status} ${res.message}'}',
|
||||
);
|
||||
return res.ok
|
||||
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
|
||||
: {'status': false, 'code': res.status, 'message': res.message};
|
||||
}
|
||||
|
||||
/// The rider cannot carry this one — it needs a van.
|
||||
///
|
||||
/// Wired but unreachable from the UI: there is no control for it anywhere in
|
||||
/// the app. Left here because the endpoint exists and the flow it belongs to
|
||||
/// is this one; adding the control is a design question about where a rider
|
||||
/// says "this doesn't fit on a bike".
|
||||
Future<Map<String, dynamic>?> requireVehicle(
|
||||
Object bookingId, {
|
||||
required String type,
|
||||
required String reason,
|
||||
}) async {
|
||||
final res = await MilerApi.vehicleRequired(
|
||||
bookingId,
|
||||
type: type,
|
||||
reason: reason,
|
||||
);
|
||||
return res.ok
|
||||
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
|
||||
: {'status': false, 'code': res.status, 'message': res.message};
|
||||
}
|
||||
}
|
||||
29
lib/providers/summary/riderweeklykms.dart
Normal file
29
lib/providers/summary/riderweeklykms.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:miler/Models/summary/riderweeklykms.dart';
|
||||
import 'package:miler/controllers/riderkm.dart';
|
||||
|
||||
class RiderWeeklyKmProvider extends ChangeNotifier {
|
||||
final RiderWeeklyKmController _controller = RiderWeeklyKmController();
|
||||
|
||||
bool isLoading = false;
|
||||
String? error;
|
||||
List<RiderWeeklyKms> kmsList = [];
|
||||
double totalKms = 0.0;
|
||||
|
||||
Future<void> fetchRiderWeeklyKms(int userId) async {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final result = await _controller.getRiderWeeklyKms(userId);
|
||||
kmsList = result['details'];
|
||||
totalKms = result['total_kms'];
|
||||
} catch (e) {
|
||||
error = e.toString();
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
50
lib/providers/summary/summary.dart
Normal file
50
lib/providers/summary/summary.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:miler/Models/summary/pickupstats.dart';
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/views/helpers/constants/apiconstants.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class SummaryProvider {
|
||||
final String baseUrl = ApiConstants.summaryApiLive;
|
||||
|
||||
Future<PickupStats?> fetchSummaryStats(int userId) =>
|
||||
_fetchSummaryStatsNew();
|
||||
|
||||
/// NEW API: GET /miler/earnings?period=daily|weekly|monthly
|
||||
/// -> data:{ completed_stops, total_kms, total_earnings, total_bonus }.
|
||||
/// PickupStats is stop COUNTS per period; map completed_stops for each.
|
||||
Future<PickupStats?> _fetchSummaryStatsNew() async {
|
||||
Future<int> stops(String period) async {
|
||||
try {
|
||||
final uri = Uri.parse(
|
||||
ApiConfig.url('/miler/earnings'),
|
||||
).replace(queryParameters: {'period': period});
|
||||
final res = await http.get(uri, headers: await ApiConfig.authHeaders());
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
final decoded = json.decode(res.body);
|
||||
final d = (decoded is Map && decoded['data'] is Map)
|
||||
? decoded['data'] as Map
|
||||
: {};
|
||||
return int.tryParse('${d['completed_stops'] ?? 0}') ?? 0;
|
||||
}
|
||||
} catch (_) {}
|
||||
return 0;
|
||||
}
|
||||
|
||||
final today = await stops('daily');
|
||||
final week = await stops('weekly');
|
||||
final month = await stops('monthly');
|
||||
ApiConfig.logGap(
|
||||
'summaryStats',
|
||||
'v1 earnings has no cancelled/total count; derived from completed_stops.',
|
||||
);
|
||||
return PickupStats(
|
||||
today: today,
|
||||
week: week,
|
||||
month: month,
|
||||
total: month,
|
||||
cancelled: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user