first commit
This commit is contained in:
348
lib/controllers/auth.dart
Normal file
348
lib/controllers/auth.dart
Normal file
@@ -0,0 +1,348 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:miler/providers/auth/auth_provider.dart';
|
||||
import 'package:miler/utils/device.dart';
|
||||
import 'package:miler/controllers/profile_controller.dart';
|
||||
import 'package:miler/Models/login/login.dart';
|
||||
import 'package:miler/data/api_config.dart';
|
||||
|
||||
enum AuthNext { verifyPin, otp, notRegistered, error }
|
||||
|
||||
class AuthController extends GetxController {
|
||||
final RxBool sendingOtp = false.obs;
|
||||
String? currentPhone;
|
||||
final AuthProvider _api = AuthProvider();
|
||||
AuthNext? lastDecision;
|
||||
// Optional callback used by MPIN screen to clear and refocus fields when user taps "Retry"
|
||||
VoidCallback? onPinRetry;
|
||||
static const String _prefsUserIdKey = 'userid';
|
||||
static const String _prefsPendingPinUserIdKey = 'pending_pin_userid';
|
||||
static const String _prefsUserNameKey = 'user_name';
|
||||
static const String _prefsUserEmailKey = 'user_email';
|
||||
static const String _prefsContactNoKey = 'contactno';
|
||||
static const String _prefsAddressKey = 'user_address';
|
||||
static const String _prefsForceMasterPinKey = 'force_master_pin';
|
||||
static const String _masterPinValue = '1234';
|
||||
static const String forceMasterPinPrefKey = _prefsForceMasterPinKey;
|
||||
static const String masterPinValue = _masterPinValue;
|
||||
bool _forceMasterPinFlow = false;
|
||||
Future<void> _notifyProfileController() async {
|
||||
try {
|
||||
if (Get.isRegistered<ProfileController>()) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final pc = Get.find<ProfileController>();
|
||||
await pc.loadFromPrefs();
|
||||
pc.setProfile(
|
||||
name: prefs.getString(_prefsUserNameKey),
|
||||
email: prefs.getString(_prefsUserEmailKey),
|
||||
contact: prefs.getString(_prefsContactNoKey),
|
||||
address: prefs.getString(_prefsAddressKey),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
String _normalizePhone(String input) {
|
||||
final digitsOnly = input.replaceAll(RegExp(r'\D'), '');
|
||||
if (digitsOnly.length >= 10) {
|
||||
return digitsOnly.substring(digitsOnly.length - 10);
|
||||
}
|
||||
return digitsOnly;
|
||||
}
|
||||
|
||||
void _showBottomSheet({required String title, required String message}) {
|
||||
Get.bottomSheet(
|
||||
Builder(
|
||||
builder: (context) => Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: 16 + MediaQuery.of(context).viewPadding.bottom,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: ColorConstants.primary, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
MilerButton(
|
||||
label: 'Retry',
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
// If MPIN screen has registered a retry callback, run it
|
||||
onPinRetry?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
|
||||
Future<AuthNext> precheckPhone(String phone) async {
|
||||
try {
|
||||
final normalized = _normalizePhone(phone);
|
||||
currentPhone = normalized;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// The mocked "Demo Rider" (userid 9999) that used to be written here is
|
||||
// gone. It bypassed the server entirely and left a fake identity in prefs
|
||||
// that outlived the session it was created for — every screen reading
|
||||
// 'userid' got 9999 until the app was reinstalled. The real user is
|
||||
// established by verify-pin and nowhere else.
|
||||
await prefs.setString(_prefsContactNoKey, normalized);
|
||||
|
||||
// On the live backend, ask whether this phone already belongs to an
|
||||
// active miler account with a PIN on file. If it does, go straight to the
|
||||
// MPIN screen: OTP delivery isn't live yet, and the OTP path ends at
|
||||
// Create-MPIN, which would overwrite the PIN the account was issued.
|
||||
// Seeded development accounts take exactly this branch — enter the phone,
|
||||
// enter the seeded MPIN, done.
|
||||
final exists = await _api.milerAccountExists(normalized);
|
||||
if (exists) {
|
||||
lastDecision = AuthNext.verifyPin;
|
||||
return lastDecision!;
|
||||
}
|
||||
|
||||
// Unknown number (or legacy backend) — fall through to the OTP step.
|
||||
lastDecision = AuthNext.otp;
|
||||
return lastDecision!;
|
||||
} catch (e) {
|
||||
debugPrint('Precheck phone error: $e');
|
||||
lastDecision = AuthNext.error;
|
||||
return lastDecision!;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> sendOtp([String? phoneArg]) async {
|
||||
if (sendingOtp.value) return false;
|
||||
if (phoneArg != null && phoneArg.isNotEmpty) {
|
||||
currentPhone = _normalizePhone(phoneArg);
|
||||
}
|
||||
sendingOtp.value = true;
|
||||
try {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
return true;
|
||||
} finally {
|
||||
sendingOtp.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> verifyOtp(String code) async {
|
||||
// Automatically succeed for mocked login
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> setPin(String newPin) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
int? userId =
|
||||
prefs.getInt(_prefsPendingPinUserIdKey) ??
|
||||
prefs.getInt(_prefsUserIdKey);
|
||||
if (newPin.length != 4 || int.tryParse(newPin) == null) {
|
||||
_showBottomSheet(
|
||||
title: 'Invalid PIN',
|
||||
message: 'Please enter a valid 4-digit PIN.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (userId == null) {
|
||||
_showBottomSheet(
|
||||
title: 'Error',
|
||||
message: 'User ID not found. Please try again.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Demo mode: the mocked login flow stores a fake rider id (9999) that
|
||||
// the live server rejects, so updatePin fails. Save the PIN locally and
|
||||
// report success so the demo Create-MPIN flow works without a real
|
||||
// account or server call.
|
||||
if (userId == 9999) {
|
||||
await prefs.setString('dbPin', newPin);
|
||||
await prefs.remove(_prefsPendingPinUserIdKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
final int pinNum = int.parse(newPin);
|
||||
final res = await _api.updatePin(userId: userId, pin: pinNum);
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
await prefs.setString('dbPin', newPin);
|
||||
await prefs.remove(_prefsPendingPinUserIdKey);
|
||||
return true;
|
||||
}
|
||||
final bodyPreview = res.body.length > 200
|
||||
? '${res.body.substring(0, 200)}...'
|
||||
: res.body;
|
||||
_showBottomSheet(
|
||||
title: 'Failed (${res.statusCode})',
|
||||
message: 'Unable to set PIN. Server said: $bodyPreview',
|
||||
);
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('setPin error: $e');
|
||||
_showBottomSheet(
|
||||
title: 'Error',
|
||||
message: 'Something went wrong while setting the PIN.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh session on backend with latest deviceId/FCM for the current phone.
|
||||
Future<bool> refreshSession({String? phone}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String? usePhone = phone ?? currentPhone;
|
||||
if (usePhone == null || usePhone.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final deviceId = await DeviceUtils.ensureDeviceId(prefs);
|
||||
final fcmToken = await DeviceUtils.ensureFcmToken(prefs);
|
||||
final Login loginRes = await _api.loginParsed(
|
||||
contactNo: usePhone,
|
||||
deviceType: Platform.operatingSystem,
|
||||
configId: 6,
|
||||
deviceId: deviceId,
|
||||
fcmToken: fcmToken,
|
||||
);
|
||||
if (loginRes.userid != null) {
|
||||
await prefs.setInt(_prefsUserIdKey, loginRes.userid!);
|
||||
}
|
||||
try {
|
||||
final String? name = loginRes.fullname ?? loginRes.firstname;
|
||||
final String? email = loginRes.email;
|
||||
final String contact = (loginRes.contactno ?? usePhone).toString();
|
||||
final String? address = loginRes.address;
|
||||
if (name != null && name.trim().isNotEmpty) {
|
||||
await prefs.setString(_prefsUserNameKey, name.trim());
|
||||
}
|
||||
if (email != null && email.trim().isNotEmpty) {
|
||||
await prefs.setString(_prefsUserEmailKey, email.trim());
|
||||
}
|
||||
if (contact.isNotEmpty) {
|
||||
final normalizedContact = _normalizePhone(contact);
|
||||
await prefs.setString(_prefsContactNoKey, normalizedContact);
|
||||
}
|
||||
if (address != null && address.trim().isNotEmpty) {
|
||||
await prefs.setString(_prefsAddressKey, address.trim());
|
||||
}
|
||||
await _notifyProfileController();
|
||||
} catch (_) {}
|
||||
currentPhone = _normalizePhone(usePhone);
|
||||
return loginRes.status == true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> verifyPinWithServer(String inputPin) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// The mocked-login bypass that used to sit here accepted ANY four digits
|
||||
// and logged the rider in without asking the server. It is gone: a PIN
|
||||
// check that cannot fail is not a PIN check.
|
||||
|
||||
// Authenticate phone + PIN against POST /miler/verify-pin. Only a real
|
||||
// success (server ok + bearer token stored) logs the rider in.
|
||||
try {
|
||||
final String phone =
|
||||
currentPhone ?? prefs.getString(_prefsContactNoKey) ?? '';
|
||||
final int? pinNum = int.tryParse(inputPin);
|
||||
if (phone.isEmpty || pinNum == null || inputPin.length != 4) {
|
||||
_showBottomSheet(
|
||||
title: 'Invalid PIN',
|
||||
message: 'Please enter your 4-digit PIN and try again.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
final deviceId = await DeviceUtils.ensureDeviceId(prefs);
|
||||
final fcmToken = await DeviceUtils.ensureFcmToken(prefs);
|
||||
final Login res = await _api.loginParsed(
|
||||
contactNo: phone,
|
||||
deviceType: Platform.operatingSystem,
|
||||
configId: 6,
|
||||
deviceId: deviceId,
|
||||
fcmToken: fcmToken,
|
||||
pin: pinNum,
|
||||
pinRaw: inputPin,
|
||||
);
|
||||
|
||||
final String? token = await ApiConfig.getToken();
|
||||
final bool ok = res.status == true && token != null && token.isNotEmpty;
|
||||
if (ok) {
|
||||
await prefs.setString('dbPin', inputPin);
|
||||
await prefs.setBool('logged_out', false);
|
||||
currentPhone = _normalizePhone(phone);
|
||||
|
||||
// Overwrite any stale demo profile with the REAL logged-in identity.
|
||||
// The UI reads the display name from 'user_name'; loginParsed only wrote
|
||||
// 'username'/'firstname', so mirror the real name/email/contact here.
|
||||
final String realName =
|
||||
(res.fullname != null && res.fullname!.trim().isNotEmpty)
|
||||
? res.fullname!.trim()
|
||||
: (prefs.getString('username') ??
|
||||
'${res.firstname ?? ''} ${res.lastname ?? ''}')
|
||||
.trim();
|
||||
if (realName.isNotEmpty) {
|
||||
await prefs.setString(_prefsUserNameKey, realName);
|
||||
}
|
||||
final String realEmail = (res.email ?? '').trim();
|
||||
if (realEmail.isNotEmpty) {
|
||||
await prefs.setString(_prefsUserEmailKey, realEmail);
|
||||
}
|
||||
await prefs.setString(_prefsContactNoKey, _normalizePhone(phone));
|
||||
|
||||
await _notifyProfileController();
|
||||
return true;
|
||||
}
|
||||
|
||||
_showBottomSheet(
|
||||
title: 'Login failed',
|
||||
message: (res.message != null && res.message!.trim().isNotEmpty)
|
||||
? res.message!
|
||||
: 'Incorrect phone number or PIN. Please try again.',
|
||||
);
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('verifyPinWithServer error: $e');
|
||||
_showBottomSheet(
|
||||
title: 'Connection error',
|
||||
message: 'Could not reach the server. Check your internet and retry.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
55
lib/controllers/connectivity_mixin.dart
Normal file
55
lib/controllers/connectivity_mixin.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
mixin ConnectivityControllerMixin on GetxController {
|
||||
final RxBool isOnline = true.obs;
|
||||
Timer? _tick;
|
||||
|
||||
@protected
|
||||
Future<bool> checkInternet() async {
|
||||
try {
|
||||
final result = await InternetAddress.lookup('example.com');
|
||||
return result.isNotEmpty && result.first.rawAddress.isNotEmpty;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
void onConnectivityOnline() {}
|
||||
|
||||
@protected
|
||||
void onConnectivityOffline() {}
|
||||
|
||||
void _startWatcher() {
|
||||
_tick?.cancel();
|
||||
_tick = Timer.periodic(const Duration(seconds: 5), (_) async {
|
||||
final ok = await checkInternet();
|
||||
final prev = isOnline.value;
|
||||
if (ok != prev) {
|
||||
isOnline.value = ok;
|
||||
if (ok) {
|
||||
onConnectivityOnline();
|
||||
} else {
|
||||
onConnectivityOffline();
|
||||
}
|
||||
} else {
|
||||
isOnline.value = ok;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_startWatcher();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_tick?.cancel();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
66
lib/controllers/duty_controller.dart
Normal file
66
lib/controllers/duty_controller.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Single source of truth for the rider's online / on-duty state.
|
||||
///
|
||||
/// Duty state used to be re-derived from raw SharedPreferences in a dozen
|
||||
/// places (homepage field, pickups gate, done screen…), and the `onduty` int is
|
||||
/// clobbered by a flaky server rider-log sync — which is what auto-kicked riders
|
||||
/// offline after a pickup. This controller centralises the read with the
|
||||
/// corrected priority so every screen agrees.
|
||||
///
|
||||
/// Priority when resolving "is the rider online?":
|
||||
/// 1. onduty == 1 → a fresh server shift is authoritative.
|
||||
/// 2. explicit `online` → the rider's toggle; SURVIVES the flaky onduty sync
|
||||
/// (every legit offline path — manual toggle, auto
|
||||
/// shift-end — sets online=false, so it's trustworthy).
|
||||
/// 3. onduty == 0 → offline.
|
||||
/// 4. otherwise → default online.
|
||||
class DutyController extends GetxController {
|
||||
/// Whether the rider is online (on duty) and may receive bookings / act.
|
||||
final RxBool isOnline = false.obs;
|
||||
|
||||
static const String _kOnduty = 'onduty';
|
||||
static const String _kOnline = 'online';
|
||||
|
||||
/// Convenience accessor — registers the controller on first use.
|
||||
static DutyController get to => Get.isRegistered<DutyController>()
|
||||
? Get.find<DutyController>()
|
||||
: Get.put(DutyController(), permanent: true);
|
||||
|
||||
/// True if the rider may receive bookings and start pickups/deliveries.
|
||||
bool get canWork => isOnline.value;
|
||||
|
||||
/// Resolves and broadcasts the current online state from persisted prefs.
|
||||
Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final int onduty = prefs.getInt(_kOnduty) ?? -1;
|
||||
final bool? savedOnline = prefs.getBool(_kOnline);
|
||||
|
||||
bool online;
|
||||
if (onduty == 1) {
|
||||
online = true;
|
||||
} else if (savedOnline != null) {
|
||||
online = savedOnline;
|
||||
} else if (onduty == 0) {
|
||||
online = false;
|
||||
} else {
|
||||
online = true;
|
||||
}
|
||||
|
||||
// Keep the persisted bool consistent so the fallback stays correct.
|
||||
await prefs.setBool(_kOnline, online);
|
||||
isOnline.value = online;
|
||||
return online;
|
||||
}
|
||||
|
||||
/// Persists + broadcasts a new online state. Call after a successful duty
|
||||
/// toggle so every listener updates immediately. Going offline also clears
|
||||
/// the raw onduty flag so a stale `1` can't resurrect an online state.
|
||||
Future<void> setOnlineLocal(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_kOnline, value);
|
||||
if (!value) await prefs.setInt(_kOnduty, 0);
|
||||
isOnline.value = value;
|
||||
}
|
||||
}
|
||||
181
lib/controllers/logcontroller.dart
Normal file
181
lib/controllers/logcontroller.dart
Normal file
@@ -0,0 +1,181 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
|
||||
import 'package:miler/views/helpers/constants/apiconstants.dart';
|
||||
import 'package:miler/background/foreground_service.dart' as fg;
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
/// Controller for managing active pickup logs
|
||||
/// Now delegates to the background service for actual logging
|
||||
class LogController extends GetxController {
|
||||
final CreatePickupLogProvider _logProvider = CreatePickupLogProvider();
|
||||
|
||||
static const String _offlineLogKey = 'offline_pickup_logs';
|
||||
bool _isFlushing = false;
|
||||
|
||||
/// Start the pickup log streaming service (via foreground service)
|
||||
Future<void> startLogging() async {
|
||||
debugPrint('[ACTIVE_COLLECTION_LOG] Requesting start logging...');
|
||||
|
||||
// Attempt to flush offline logs on start
|
||||
flushOfflineLogs();
|
||||
|
||||
// Only show foreground notification when rider is actually on duty
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final int onduty = prefs.getInt('onduty') ?? 0;
|
||||
if (onduty != 1) {
|
||||
debugPrint(
|
||||
'[ACTIVE_COLLECTION_LOG] Skipping startLogging because onduty=$onduty',
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// If prefs fail, continue with best-effort start
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
if (await FlutterForegroundTask.isRunningService) {
|
||||
debugPrint(
|
||||
'[ACTIVE_COLLECTION_LOG] Foreground service already running',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[ACTIVE_COLLECTION_LOG] Starting foreground service for collection logs',
|
||||
);
|
||||
|
||||
FlutterForegroundTask.init(
|
||||
androidNotificationOptions: AndroidNotificationOptions(
|
||||
channelId: 'doormile_bg_service',
|
||||
channelName: 'Background Service',
|
||||
channelDescription:
|
||||
'Keeps Miler online updates running in background.',
|
||||
channelImportance: NotificationChannelImportance.LOW,
|
||||
priority: NotificationPriority.LOW,
|
||||
),
|
||||
iosNotificationOptions: const IOSNotificationOptions(
|
||||
showNotification: true,
|
||||
playSound: false,
|
||||
),
|
||||
foregroundTaskOptions: ForegroundTaskOptions(
|
||||
interval: 30000, // 30 seconds
|
||||
isOnceEvent: false,
|
||||
autoRunOnBoot: false,
|
||||
allowWakeLock: true,
|
||||
allowWifiLock: true,
|
||||
),
|
||||
);
|
||||
|
||||
// Check permissions before starting service to prevent Android 14 crash
|
||||
final permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied ||
|
||||
permission == LocationPermission.deniedForever) {
|
||||
debugPrint(
|
||||
'[ACTIVE_COLLECTION_LOG] Location permission missing, skipping service start',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await FlutterForegroundTask.startService(
|
||||
notificationTitle: 'Miler is running',
|
||||
notificationText: 'You are Currently on Duty !',
|
||||
callback: fg.riderLogCallback,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[ACTIVE_COLLECTION_LOG] Failed to start service: $e');
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'[ACTIVE_COLLECTION_LOG] iOS/Web not fully supported for background service yet',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the pickup log streaming service
|
||||
/// Note: This might stop rider logs too if they share the service.
|
||||
/// Usually we only stop if the user goes off-duty or logs out.
|
||||
void stopLogging() {
|
||||
debugPrint(
|
||||
'[ACTIVE_COLLECTION_LOG] Stop logging requested (no-op to preserve rider logs)',
|
||||
);
|
||||
// We do not stop the service here because it might be running for Rider Logs.
|
||||
// The service should be stopped by RiderLogController when going off-duty.
|
||||
}
|
||||
|
||||
// ---------------- Offline Queue Logic (Foreground Helper) ----------------
|
||||
|
||||
/// Call this on app start or network restoration
|
||||
Future<void> flushOfflineLogs() async {
|
||||
if (_isFlushing) return;
|
||||
_isFlushing = true;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final List<String> queue = prefs.getStringList(_offlineLogKey) ?? [];
|
||||
if (queue.isEmpty) return;
|
||||
|
||||
debugPrint(
|
||||
'[ACTIVE_COLLECTION_LOG][OFFLINE] Flushing ${queue.length} offline logs...',
|
||||
);
|
||||
|
||||
final List<String> remaining = [];
|
||||
bool anySuccess = false;
|
||||
|
||||
|
||||
for (final itemStr in queue) {
|
||||
try {
|
||||
final Map<String, dynamic> item = jsonDecode(itemStr);
|
||||
final String orderId = item['orderId'] ?? '';
|
||||
final Map<String, dynamic> payload = Map<String, dynamic>.from(
|
||||
item['payload'] ?? {},
|
||||
);
|
||||
|
||||
if (payload.isEmpty) continue;
|
||||
|
||||
debugPrint(
|
||||
'[ACTIVE_COLLECTION_LOG][OFFLINE] Retrying for orderId: $orderId',
|
||||
);
|
||||
|
||||
final result = await _logProvider
|
||||
.createPickupLog(payload)
|
||||
.timeout(const Duration(seconds: 8));
|
||||
|
||||
if (result != null) {
|
||||
debugPrint(
|
||||
'[ACTIVE_COLLECTION_LOG][OFFLINE] Success for orderId: $orderId',
|
||||
);
|
||||
anySuccess = true;
|
||||
} else {
|
||||
remaining.add(itemStr);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[ACTIVE_COLLECTION_LOG][OFFLINE] Error processing item: $e',
|
||||
);
|
||||
remaining.add(itemStr);
|
||||
}
|
||||
}
|
||||
|
||||
if (anySuccess || remaining.length != queue.length) {
|
||||
await prefs.setStringList(_offlineLogKey, remaining);
|
||||
debugPrint(
|
||||
'[ACTIVE_COLLECTION_LOG][OFFLINE] Flush complete. Remaining: ${remaining.length}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[ACTIVE_COLLECTION_LOG][OFFLINE] Flush error: $e');
|
||||
} finally {
|
||||
_isFlushing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
123
lib/controllers/pickup.dart
Normal file
123
lib/controllers/pickup.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class PickupController extends GetxController {
|
||||
// Exposed reactive fields if needed in UI
|
||||
final RxString orderId = ''.obs;
|
||||
final RxString orderStatus = ''.obs;
|
||||
final RxInt tenantId = 0.obs;
|
||||
final RxInt partnerId = 0.obs;
|
||||
final RxInt locationId = 0.obs;
|
||||
final RxInt orderHeaderId = 0.obs;
|
||||
final RxInt pickupId = 0.obs;
|
||||
final RxInt userId = 0.obs;
|
||||
final RxString latitude = ''.obs;
|
||||
final RxString longitude = ''.obs;
|
||||
|
||||
// Preference keys (aligned with existing usage in app)
|
||||
static const String kTenantId = 'pickup_tenantid';
|
||||
static const String kPartnerId = 'pickup_partnerid';
|
||||
static const String kLocationId = 'pickup_locationid';
|
||||
static const String kOrderHeaderId = 'pickup_orderheaderid';
|
||||
static const String kPickupId = 'pickup_pickupid';
|
||||
static const String kUserId = 'pickup_userid';
|
||||
static const String kOrderId = 'pickup_orderid';
|
||||
static const String kOrderStatus = 'pickup_orderstatus';
|
||||
static const String kLat = 'pickup_latitude';
|
||||
static const String kLng = 'pickup_longitude';
|
||||
|
||||
Future<void> saveFromQueueItem(Map<String, dynamic> pickup) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final int? tenantid = pickup['tenantid'] is int
|
||||
? pickup['tenantid'] as int
|
||||
: int.tryParse('${pickup['tenantid'] ?? ''}');
|
||||
final int? partnerid = pickup['partnerid'] is int
|
||||
? pickup['partnerid'] as int
|
||||
: int.tryParse('${pickup['partnerid'] ?? ''}');
|
||||
final int? locationid = pickup['locationid'] is int
|
||||
? pickup['locationid'] as int
|
||||
: int.tryParse('${pickup['locationid'] ?? ''}');
|
||||
final int? orderheaderid = pickup['orderheaderid'] is int
|
||||
? pickup['orderheaderid'] as int
|
||||
: int.tryParse('${pickup['orderheaderid'] ?? ''}');
|
||||
final int? pickupid = pickup['pickupid'] is int
|
||||
? pickup['pickupid'] as int
|
||||
: int.tryParse('${pickup['pickupid'] ?? ''}');
|
||||
final int? userid = pickup['userid'] is int
|
||||
? pickup['userid'] as int
|
||||
: int.tryParse('${pickup['userid'] ?? ''}');
|
||||
|
||||
final String oid = (pickup['orderid'] ?? '').toString();
|
||||
final String ostatus = (pickup['orderstatus'] ?? '').toString();
|
||||
|
||||
// Prefer pickuplat/long, fallback to droplat/lon
|
||||
final String lat = (pickup['pickuplat'] ?? pickup['droplat'] ?? '')
|
||||
.toString();
|
||||
final String lon = (pickup['pickuplong'] ?? pickup['droplon'] ?? '')
|
||||
.toString();
|
||||
|
||||
if (tenantid != null) await prefs.setInt(kTenantId, tenantid);
|
||||
if (partnerid != null) await prefs.setInt(kPartnerId, partnerid);
|
||||
if (locationid != null) await prefs.setInt(kLocationId, locationid);
|
||||
if (orderheaderid != null)
|
||||
await prefs.setInt(kOrderHeaderId, orderheaderid);
|
||||
if (pickupid != null) await prefs.setInt(kPickupId, pickupid);
|
||||
if (userid != null) await prefs.setInt(kUserId, userid);
|
||||
if (oid.isNotEmpty) await prefs.setString(kOrderId, oid);
|
||||
if (ostatus.isNotEmpty) await prefs.setString(kOrderStatus, ostatus);
|
||||
if (lat.isNotEmpty) await prefs.setString(kLat, lat);
|
||||
if (lon.isNotEmpty) await prefs.setString(kLng, lon);
|
||||
|
||||
// Update observables
|
||||
orderId.value = oid;
|
||||
orderStatus.value = ostatus;
|
||||
tenantId.value = tenantid ?? 0;
|
||||
partnerId.value = partnerid ?? 0;
|
||||
locationId.value = locationid ?? 0;
|
||||
orderHeaderId.value = orderheaderid ?? 0;
|
||||
pickupId.value = pickupid ?? 0;
|
||||
userId.value = userid ?? 0;
|
||||
latitude.value = lat;
|
||||
longitude.value = lon;
|
||||
}
|
||||
|
||||
Future<void> loadFromPrefs() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
tenantId.value = prefs.getInt(kTenantId) ?? 0;
|
||||
partnerId.value = prefs.getInt(kPartnerId) ?? 0;
|
||||
locationId.value = prefs.getInt(kLocationId) ?? 0;
|
||||
orderHeaderId.value = prefs.getInt(kOrderHeaderId) ?? 0;
|
||||
pickupId.value = prefs.getInt(kPickupId) ?? 0;
|
||||
userId.value = prefs.getInt(kUserId) ?? 0;
|
||||
orderId.value = prefs.getString(kOrderId) ?? '';
|
||||
orderStatus.value = prefs.getString(kOrderStatus) ?? '';
|
||||
latitude.value = prefs.getString(kLat) ?? '';
|
||||
longitude.value = prefs.getString(kLng) ?? '';
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(kTenantId);
|
||||
await prefs.remove(kPartnerId);
|
||||
await prefs.remove(kLocationId);
|
||||
await prefs.remove(kOrderHeaderId);
|
||||
await prefs.remove(kPickupId);
|
||||
await prefs.remove(kUserId);
|
||||
await prefs.remove(kOrderId);
|
||||
await prefs.remove(kOrderStatus);
|
||||
await prefs.remove(kLat);
|
||||
await prefs.remove(kLng);
|
||||
|
||||
orderId.value = '';
|
||||
orderStatus.value = '';
|
||||
tenantId.value = 0;
|
||||
partnerId.value = 0;
|
||||
locationId.value = 0;
|
||||
orderHeaderId.value = 0;
|
||||
pickupId.value = 0;
|
||||
userId.value = 0;
|
||||
latitude.value = '';
|
||||
longitude.value = '';
|
||||
}
|
||||
}
|
||||
1981
lib/controllers/pickups_controller.dart
Normal file
1981
lib/controllers/pickups_controller.dart
Normal file
File diff suppressed because it is too large
Load Diff
58
lib/controllers/profile_controller.dart
Normal file
58
lib/controllers/profile_controller.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'dart:io';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class ProfileController extends GetxController {
|
||||
final RxString imagePath = ''.obs;
|
||||
final RxString userName = ''.obs;
|
||||
final RxString userEmail = ''.obs;
|
||||
final RxString userContact = ''.obs;
|
||||
final RxString userAddress = ''.obs;
|
||||
|
||||
void setImagePath(String path) {
|
||||
imagePath.value = path;
|
||||
}
|
||||
|
||||
Future<void> loadFromPrefs() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
// The picked photo's path was being written on every change and never read
|
||||
// back, so the avatar reverted to the initial on every cold start. It is
|
||||
// restored here, and dropped if the file has since gone from disk — an
|
||||
// image-picker path can point into a cache the OS is free to clear.
|
||||
final String savedImage = (prefs.getString('profile_image_path') ?? '')
|
||||
.trim();
|
||||
if (savedImage.isNotEmpty) {
|
||||
bool stillThere = false;
|
||||
try {
|
||||
stillThere = File(savedImage).existsSync();
|
||||
} catch (_) {}
|
||||
imagePath.value = stillThere ? savedImage : '';
|
||||
if (!stillThere) await prefs.remove('profile_image_path');
|
||||
}
|
||||
userName.value = (prefs.getString('user_name') ?? '').trim();
|
||||
userEmail.value = (prefs.getString('user_email') ?? '').trim();
|
||||
userContact.value = (prefs.getString('contactno') ?? '').trim();
|
||||
userAddress.value = (prefs.getString('user_address') ?? '').trim();
|
||||
}
|
||||
|
||||
void setProfile({
|
||||
String? name,
|
||||
String? email,
|
||||
String? contact,
|
||||
String? address,
|
||||
}) {
|
||||
if (name != null && name.trim().isNotEmpty) userName.value = name.trim();
|
||||
if (email != null && email.trim().isNotEmpty)
|
||||
userEmail.value = email.trim();
|
||||
if (contact != null && contact.trim().isNotEmpty)
|
||||
userContact.value = contact.trim();
|
||||
if (address != null && address.trim().isNotEmpty)
|
||||
userAddress.value = address.trim();
|
||||
}
|
||||
|
||||
File? get fileOrNull {
|
||||
final path = imagePath.value;
|
||||
if (path.isEmpty) return null;
|
||||
return File(path);
|
||||
}
|
||||
}
|
||||
45
lib/controllers/rewards_controller.dart
Normal file
45
lib/controllers/rewards_controller.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
|
||||
/// The rider's reward-points balance.
|
||||
///
|
||||
/// ── Two things this used to do and no longer does ──
|
||||
///
|
||||
/// It called a second, legacy backend (`jupiter.doormile.app/.../
|
||||
/// getuserbonussummary`) when the new-API flag was off, and it filled an empty
|
||||
/// balance with seeded demo points in debug builds. Both are gone: there is one
|
||||
/// backend now, and a points balance is a number about somebody's pay — the
|
||||
/// only honest answer to "the server said zero" is zero.
|
||||
///
|
||||
/// ── Why the number is expected to be zero ──
|
||||
///
|
||||
/// `bonuspoints` is not written by anything server-side yet. `total_bonus` on
|
||||
/// the earnings response is therefore always 0, and that is the backend's
|
||||
/// answer rather than a bug here. See [MilerApi.missingFromBackend].
|
||||
class RewardsController extends GetxController {
|
||||
final RxInt totalPoints = 0.obs;
|
||||
final RxBool isLoading = true.obs;
|
||||
final RxString error = ''.obs;
|
||||
|
||||
Future<void> fetchBonusSummary(int userId) async {
|
||||
isLoading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
// No dedicated bonus-summary endpoint exists; the figure rides on
|
||||
// earnings.
|
||||
final res = await MilerApi.earnings(period: 'monthly');
|
||||
if (res.ok) {
|
||||
totalPoints.value =
|
||||
int.tryParse('${res.map['total_bonus'] ?? 0}') ?? 0;
|
||||
} else {
|
||||
error.value = res.message.isEmpty
|
||||
? "Couldn't load your points"
|
||||
: res.message;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = "Couldn't load your points";
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
31
lib/controllers/riderkm.dart
Normal file
31
lib/controllers/riderkm.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:miler/Models/summary/riderweeklykms.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
import 'package:miler/views/helpers/constants/apiconstants.dart';
|
||||
|
||||
class RiderWeeklyKmController {
|
||||
final String baseUrl = ApiConstants.summaryriderkmLive;
|
||||
|
||||
/// `GET /miler/earnings?period=weekly` → `data:{ total_kms, breakdown }`.
|
||||
///
|
||||
/// [userId] is unused: the token identifies the rider, and asking for someone
|
||||
/// else's kilometres is not a thing the endpoint offers. Kept on the
|
||||
/// signature because the provider above passes it and the call sites read
|
||||
/// better for naming whose distance they mean.
|
||||
Future<Map<String, dynamic>> getRiderWeeklyKms(int userId) async {
|
||||
final res = await MilerApi.earnings(period: 'weekly');
|
||||
if (!res.ok) {
|
||||
throw Exception('Failed to fetch (code: ${res.status})');
|
||||
}
|
||||
final breakdown = res.map['breakdown'];
|
||||
final details = (breakdown is List ? breakdown : const [])
|
||||
.whereType<Map>()
|
||||
.map((e) => RiderWeeklyKms.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
return {
|
||||
'details': details,
|
||||
'total_kms': (res.map['total_kms'] ?? 0).toDouble(),
|
||||
};
|
||||
}
|
||||
}
|
||||
1255
lib/controllers/riderlog.dart
Normal file
1255
lib/controllers/riderlog.dart
Normal file
File diff suppressed because it is too large
Load Diff
58
lib/controllers/summary_controller.dart
Normal file
58
lib/controllers/summary_controller.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:miler/Models/summary/pickupstats.dart';
|
||||
import 'package:miler/providers/summary/summary.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class SummaryController extends GetxController {
|
||||
final SummaryProvider _provider = SummaryProvider();
|
||||
|
||||
// Observables
|
||||
var today = 0.obs;
|
||||
var week = 0.obs;
|
||||
var month = 0.obs;
|
||||
var total = 0.obs;
|
||||
var cancelled = 0.obs;
|
||||
var isLoading = false.obs;
|
||||
|
||||
// Fetch stats and update values
|
||||
Future<void> fetchSummaryStats(int userId) async {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
final PickupStats? stats = await _provider.fetchSummaryStats(userId);
|
||||
// A record of all zeros is what a freshly-seeded account returns, and it
|
||||
// is indistinguishable from no record at all — treat both as empty so
|
||||
// the demo figures can fill in.
|
||||
if (stats != null && (stats.total > 0 || stats.month > 0)) {
|
||||
today.value = stats.today;
|
||||
week.value = stats.week;
|
||||
month.value = stats.month;
|
||||
total.value = stats.total;
|
||||
cancelled.value = stats.cancelled;
|
||||
} else {
|
||||
_applyZero();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Controller error: $e');
|
||||
_applyZero();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing came back — show zeros.
|
||||
///
|
||||
/// This used to fill the tab with seeded demo figures in debug builds. That
|
||||
/// was defensible while the endpoint was unfinished and indefensible now that
|
||||
/// it is live: the one thing a pay screen must never do is invent a number,
|
||||
/// and a debug-only invention is still the number the person testing the app
|
||||
/// reports back as real. An empty Earnings tab is information — it says the
|
||||
/// fetch returned nothing — and the honest zero is what makes a broken
|
||||
/// endpoint visible instead of plausible.
|
||||
void _applyZero() {
|
||||
today.value = 0;
|
||||
week.value = 0;
|
||||
month.value = 0;
|
||||
total.value = 0;
|
||||
cancelled.value = 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user