first commit

This commit is contained in:
2026-08-11 13:16:33 +05:30
parent 18531bf46b
commit c8350563d9
388 changed files with 69656 additions and 0 deletions

View 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');
});
}

View 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'},
);
}
}

View 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 (_) {}
}
}

View 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();
}

View 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};
}
}

View 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();
}
}

View 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,
);
}
}