updated the sheet

This commit is contained in:
2026-08-28 18:16:28 +05:30
parent 69f4f3e909
commit 074cc0eccf
43 changed files with 2617 additions and 1201 deletions

19
.gitignore vendored
View File

@@ -49,3 +49,22 @@ app.*.map.json
# every failing run — 128 of them were sitting untracked. The references # every failing run — 128 of them were sitting untracked. The references
# themselves live in test/shots and ARE committed; these are the diff output. # themselves live in test/shots and ARE committed; these are the diff output.
test/failures/ test/failures/
# ── Signing material never goes in the repo ──
#
# `android/key.properties` holds the keystore passwords in plain text and the
# `.jks` files are the signing keys themselves. Anyone with both can publish an
# update to the app under its own identity, so a copy in git is a copy in every
# clone, every fork and every CI cache, for as long as the history exists.
#
# The keys that were already committed (nearlerider-keystore.jks,
# release-key.jks) are unused — the app signs with a key generated outside the
# repo — but they are still in the history and should be treated as public.
# They are left tracked deliberately: removing them from the working tree does
# not remove them from history, and rewriting history is the repo owner's call.
#
# Keep the keystore and its passwords in a password manager or a CI secret
# store. Losing them means never being able to update the app on Play again.
android/key.properties
*.jks
*.keystore

View File

@@ -1,6 +1,6 @@
# Miler # Miler
The Doormile rider app. One Android/iOS build that a rider signs into, gets his The Doormile Miler app. One Android/iOS build that a rider signs into, gets his
work for the day, and reports it back from the doorstep. work for the day, and reports it back from the doorstep.
`com.doormile.partner` · Flutter · GetX for the controllers that need it · `com.doormile.partner` · Flutter · GetX for the controllers that need it ·

View File

@@ -33,7 +33,7 @@
<!-- ===== Application ===== --> <!-- ===== Application ===== -->
<!-- usesCleartextTraffic was `true`, commented "allows http if any". There <!-- usesCleartextTraffic was `true`, commented "allows http if any". There
is no http:// endpoint left in the app: every host it talks to — is no http:// endpoint left in the app: every host it talks to —
api.doormile.com, queue.workolik.com, the OSM tile server and the OSRM api.doormile.com, the OSM tile server and the OSRM
router — is https, and all of them present valid publicly trusted router — is https, and all of them present valid publicly trusted
certificates. certificates.
@@ -44,7 +44,7 @@
the certificate bypass in MyHttpOverrides, which was the other half the certificate bypass in MyHttpOverrides, which was the other half
of the same hole. --> of the same hole. -->
<application <application
android:label="Doormile Rider" android:label="Miler"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="false"> android:usesCleartextTraffic="false">

View File

@@ -1,278 +1,232 @@
package com.doormile.partner package com.doormile.partner
import android.app.AlarmManager import android.app.AlarmManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.PowerManager import android.os.PowerManager
import android.util.Log import android.util.Log
import java.io.OutputStreamWriter import java.io.OutputStreamWriter
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
/** /**
* BroadcastReceiver that triggers when shift end alarm fires * BroadcastReceiver that triggers when shift end alarm fires
* This works even when app is killed * This works even when app is killed
* *
* When app is killed, we directly call the API to create break log * When app is killed, we directly call the API to create break log
* since we can't reliably start Flutter engine * since we can't reliably start Flutter engine
*/ */
class ShiftEndReceiver : BroadcastReceiver() { class ShiftEndReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
Log.d("ShiftEndReceiver", "🔔 Shift end alarm triggered at ${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Calendar.getInstance().time)}") Log.d("ShiftEndReceiver", "🔔 Shift end alarm triggered at ${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Calendar.getInstance().time)}")
// ✅ CRITICAL: Acquire WakeLock to keep device awake during API call // ✅ CRITICAL: Acquire WakeLock to keep device awake during API call
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ShiftEndReceiver::WakeLock") val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ShiftEndReceiver::WakeLock")
wakeLock.acquire(60 * 1000L) // Hold for 60 seconds max wakeLock.acquire(60 * 1000L) // Hold for 60 seconds max
// Execute API call in background thread // Execute API call in background thread
Thread { Thread {
try { try {
// Check if rider is still on duty // Check if rider is still on duty
val prefs: SharedPreferences = context.getSharedPreferences( val prefs: SharedPreferences = context.getSharedPreferences(
"FlutterSharedPreferences", "FlutterSharedPreferences",
Context.MODE_PRIVATE Context.MODE_PRIVATE
) )
// ✅ Try multiple key formats (Flutter SharedPreferences uses "flutter." prefix) // ✅ Try multiple key formats (Flutter SharedPreferences uses "flutter." prefix)
val onduty = prefs.getInt("flutter.onduty", 0) val onduty = prefs.getInt("flutter.onduty", 0)
Log.d("ShiftEndReceiver", "Checking onduty: $onduty") Log.d("ShiftEndReceiver", "Checking onduty: $onduty")
if (onduty != 1) { if (onduty != 1) {
Log.d("ShiftEndReceiver", "Rider already offline (onduty=$onduty), skipping break log creation") Log.d("ShiftEndReceiver", "Rider already offline (onduty=$onduty), skipping break log creation")
return@Thread return@Thread
} }
// Get required IDs - try both flutter.userid and flutter.userId // Get required IDs - try both flutter.userid and flutter.userId
val userid = prefs.getInt("flutter.userid", 0).takeIf { it != 0 } val userid = prefs.getInt("flutter.userid", 0).takeIf { it != 0 }
?: prefs.getInt("flutter.userId", 0) ?: prefs.getInt("flutter.userId", 0)
val partnerid = prefs.getInt("flutter.partnerid", 0).takeIf { it != 0 }
?: prefs.getInt("flutter.partnerId", 0) if (userid == 0) {
val shiftid = prefs.getInt("flutter.shiftid", 0).takeIf { it != 0 } Log.e("ShiftEndReceiver", "❌ Missing userid, cannot create break log. Available keys: ${prefs.all.keys}")
?: prefs.getInt("flutter.shiftId", 0) return@Thread
val logid = prefs.getInt("flutter.logid", 0).takeIf { it != 0 } }
?: prefs.getInt("flutter.logId", 0)
// ── v1, with the session, and no ids in the body ──
Log.d("ShiftEndReceiver", "Retrieved IDs: userid=$userid, partnerid=$partnerid, shiftid=$shiftid, logid=$logid") //
// This block hand-posted two legacy routes over HttpURLConnection:
if (userid == 0) { // partners/createbreaklog and partners/updateriderlog, on
Log.e("ShiftEndReceiver", "❌ Missing userid, cannot create break log. Available keys: ${prefs.all.keys}") // the retired pre-v1 backend,
return@Thread // with `userid`, `partnerid`, `shiftid` and `logid` in the body and
} // NO Authorization header — an unauthenticated write keyed on ids
// read out of SharedPreferences.
// Get API base URL (check if live or dev) //
// ✅ Match homepage logic: use createbreaklog (not createbreakriderlog) // The v1 equivalents key on the bearer token instead: the server
val mainRoute = prefs.getString("flutter.mainRoute", "dev") ?: "dev" // knows which rider the session names, so the ids are neither sent
val isLive = mainRoute == "live" // nor needed. Same two effects, one host, one auth model as the
Log.d("ShiftEndReceiver", "API Route: $mainRoute (isLive=$isLive)") // rest of the app.
val baseUrl = if (isLive) { //
"https://jupiter.doormile.app/live/api/v2/partners/createbreaklog" // No token means no shift to end. That is the correct outcome —
} else { // a signed-out handset must not be able to close somebody's duty.
"https://jupiter.doormile.app/dev/api/v2/partners/createbreaklog" val token = prefs.getString("flutter.authtoken", "") ?: ""
} if (token.isEmpty()) {
Log.e("ShiftEndReceiver", "No session token; cannot end shift")
// Create break log payload return@Thread
val now = Calendar.getInstance() }
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.US) val breakOk = callV1(
val breakdate = dateFormat.format(now.time) path = "/miler/breaks/start",
val breakstart = timeFormat.format(now.time) method = "POST",
val localBreakId = (System.currentTimeMillis() % 900).toInt() + 100 token = token,
body = """{"breaktype":"Shift_End"}"""
// ✅ Compact JSON (no extra whitespace) - matches Flutter format )
val payload = """{"breakid":$localBreakId,"logid":$logid,"breakdate":"$breakdate","userid":$userid,"partnerid":$partnerid,"shiftid":$shiftid,"breakstart":"$breakstart","breakend":"","breakhours":0.0,"latitude":"0","longitude":"0"}"""
if (breakOk) {
Log.d("ShiftEndReceiver", "📤 Sending break log request to: $baseUrl") Log.d("ShiftEndReceiver", "Break log created successfully")
Log.d("ShiftEndReceiver", "📦 Payload: $payload")
prefs.edit()
// Make API call .putInt("flutter.onduty", 0)
val url = URL(baseUrl) .putBoolean("flutter.online", false)
val connection = url.openConnection() as HttpURLConnection .apply()
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json") // `PUT /miler/duty/end` takes no body and closes whichever duty
connection.setRequestProperty("Accept", "application/json") // log is open for the token's rider.
connection.doOutput = true if (callV1("/miler/duty/end", "PUT", token, null)) {
connection.doInput = true Log.d("ShiftEndReceiver", "Rider status updated to Offline")
connection.useCaches = false }
connection.connectTimeout = 15000 // Increased timeout } else {
connection.readTimeout = 15000 Log.e("ShiftEndReceiver", "Failed to create break log")
}
Log.d("ShiftEndReceiver", "🔌 Connecting to API...")
// ✅ CRITICAL: Reschedule alarm for tomorrow (so it works every day automatically)
// Write payload // This ensures the alarm fires every day at shift end time even if app is killed
val outputStream = connection.outputStream try {
val writer = OutputStreamWriter(outputStream, "UTF-8") val endTimeStr = prefs.getString("flutter.endtime", "") ?: ""
writer.write(payload) val startTimeStr = prefs.getString("flutter.starttime", "") ?: ""
writer.flush() Log.d("ShiftEndReceiver", "Rescheduling alarm - endTime: $endTimeStr, startTime: $startTimeStr")
writer.close()
if (endTimeStr.isNotEmpty()) {
Log.d("ShiftEndReceiver", "📨 Request sent, waiting for response...") // Schedule alarm for tomorrow at the same time
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val responseCode = connection.responseCode val intent = Intent(context, ShiftEndReceiver::class.java).apply {
Log.d("ShiftEndReceiver", "📥 Break log API response: HTTP $responseCode") action = "com.doormile.partner.SHIFT_END_ALARM"
}
// Read response body for debugging val pendingIntent = PendingIntent.getBroadcast(
try { context,
val responseStream = if (responseCode in 200..299) { 1001,
connection.inputStream intent,
} else { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
connection.errorStream )
}
if (responseStream != null) { val timeParts = endTimeStr.split(":")
val responseBody = responseStream.bufferedReader().use { it.readText() } if (timeParts.size >= 2) {
Log.d("ShiftEndReceiver", "📄 Response body: $responseBody") val endHour = timeParts[0].toIntOrNull() ?: 0
} val endMinute = timeParts[1].toIntOrNull() ?: 0
} catch (e: Exception) {
Log.w("ShiftEndReceiver", "Could not read response body: ${e.message}") val calendar = Calendar.getInstance().apply {
} add(Calendar.DAY_OF_MONTH, 1) // Tomorrow
set(Calendar.HOUR_OF_DAY, endHour)
if (responseCode in 200..299) { set(Calendar.MINUTE, endMinute)
Log.d("ShiftEndReceiver", "✅ Break log created successfully (HTTP $responseCode)") set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
// Set offline locally }
prefs.edit()
.putInt("flutter.onduty", 0) if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
.putBoolean("flutter.online", false) if (alarmManager.canScheduleExactAlarms()) {
.apply() alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
// Also update rider log to set onduty=0 calendar.timeInMillis,
val updateUrl = if (isLive) { pendingIntent
"https://jupiter.doormile.app/live/api/v2/partners/updateriderlog" )
} else { Log.d("ShiftEndReceiver", "✅ Rescheduled alarm for tomorrow: ${calendar.time}")
"https://jupiter.doormile.app/dev/api/v2/partners/updateriderlog" } else {
} alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
// ✅ Compact JSON (no extra whitespace) calendar.timeInMillis,
val updatePayload = """{"userid":$userid,"onduty":0,"latitude":"0","longitude":"0"}""" pendingIntent
)
Log.d("ShiftEndReceiver", "📤 Updating rider status to offline") }
} else {
try { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
val updateConnection = URL(updateUrl).openConnection() as HttpURLConnection alarmManager.setExactAndAllowWhileIdle(
updateConnection.requestMethod = "POST" AlarmManager.RTC_WAKEUP,
updateConnection.setRequestProperty("Content-Type", "application/json") calendar.timeInMillis,
updateConnection.doOutput = true pendingIntent
updateConnection.connectTimeout = 5000 )
updateConnection.readTimeout = 5000 } else {
@Suppress("DEPRECATION")
val updateWriter = OutputStreamWriter(updateConnection.outputStream, "UTF-8") alarmManager.setExact(
updateWriter.write(updatePayload) AlarmManager.RTC_WAKEUP,
updateWriter.flush() calendar.timeInMillis,
updateWriter.close() pendingIntent
)
if (updateConnection.responseCode in 200..299) { }
Log.d("ShiftEndReceiver", "✅ Rider status updated to Offline") Log.d("ShiftEndReceiver", "✅ Rescheduled alarm for tomorrow: ${calendar.time}")
} }
updateConnection.disconnect() }
} catch (e: Exception) { }
Log.e("ShiftEndReceiver", "Error updating rider status: ${e.message}") } catch (e: Exception) {
} Log.e("ShiftEndReceiver", "Error rescheduling alarm: ${e.message}", e)
} else { }
// Read error response for debugging } catch (e: Exception) {
try { Log.e("ShiftEndReceiver", "❌ Error handling shift end: ${e.message}", e)
val errorStream = connection.errorStream e.printStackTrace()
if (errorStream != null) { } finally {
val errorResponse = errorStream.bufferedReader().use { it.readText() } // ✅ CRITICAL: Release WakeLock in finally block
Log.e("ShiftEndReceiver", "❌ Failed to create break log: HTTP $responseCode\nError: $errorResponse") try {
} else { if (wakeLock.isHeld) {
Log.e("ShiftEndReceiver", "❌ Failed to create break log: HTTP $responseCode") wakeLock.release()
} Log.d("ShiftEndReceiver", "🔓 WakeLock released")
} catch (e: Exception) { }
Log.e("ShiftEndReceiver", "❌ Failed to create break log: HTTP $responseCode (Error reading response: ${e.message})") } catch (e: Exception) {
} Log.e("ShiftEndReceiver", "Error releasing WakeLock: ${e.message}")
} }
}
connection.disconnect() }.start()
}
// ✅ CRITICAL: Reschedule alarm for tomorrow (so it works every day automatically)
// This ensures the alarm fires every day at shift end time even if app is killed /**
try { * One authenticated call to the v1 backend. Returns true on 2xx.
val endTimeStr = prefs.getString("flutter.endtime", "") ?: "" *
val startTimeStr = prefs.getString("flutter.starttime", "") ?: "" * The single place this file talks to a network, so there is one base URL,
Log.d("ShiftEndReceiver", "Rescheduling alarm - endTime: $endTimeStr, startTime: $startTimeStr") * one auth model and one timeout policy rather than a copy per endpoint.
*/
if (endTimeStr.isNotEmpty()) { private fun callV1(path: String, method: String, token: String, body: String?): Boolean {
// Schedule alarm for tomorrow at the same time var connection: HttpURLConnection? = null
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager return try {
val intent = Intent(context, ShiftEndReceiver::class.java).apply { connection = URL(V1_BASE + path).openConnection() as HttpURLConnection
action = "com.doormile.partner.SHIFT_END_ALARM" connection.requestMethod = method
} connection.setRequestProperty("Content-Type", "application/json")
val pendingIntent = PendingIntent.getBroadcast( connection.setRequestProperty("Accept", "application/json")
context, connection.setRequestProperty("Authorization", "Bearer $token")
1001, connection.connectTimeout = 15000
intent, connection.readTimeout = 15000
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE if (body != null) {
) connection.doOutput = true
OutputStreamWriter(connection.outputStream, "UTF-8").use {
val timeParts = endTimeStr.split(":") it.write(body)
if (timeParts.size >= 2) { it.flush()
val endHour = timeParts[0].toIntOrNull() ?: 0 }
val endMinute = timeParts[1].toIntOrNull() ?: 0 }
val code = connection.responseCode
val calendar = Calendar.getInstance().apply { Log.d("ShiftEndReceiver", "$method $path -> HTTP $code")
add(Calendar.DAY_OF_MONTH, 1) // Tomorrow code in 200..299
set(Calendar.HOUR_OF_DAY, endHour) } catch (e: Exception) {
set(Calendar.MINUTE, endMinute) Log.e("ShiftEndReceiver", "$method $path failed: ${e.message}")
set(Calendar.SECOND, 0) false
set(Calendar.MILLISECOND, 0) } finally {
} connection?.disconnect()
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { }
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle( companion object {
AlarmManager.RTC_WAKEUP, /** The one backend this app talks to. */
calendar.timeInMillis, private const val V1_BASE = "https://api.doormile.com/api/v1"
pendingIntent }
)
Log.d("ShiftEndReceiver", "✅ Rescheduled alarm for tomorrow: ${calendar.time}") }
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
calendar.timeInMillis,
pendingIntent
)
}
} else {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
calendar.timeInMillis,
pendingIntent
)
} else {
@Suppress("DEPRECATION")
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
calendar.timeInMillis,
pendingIntent
)
}
Log.d("ShiftEndReceiver", "✅ Rescheduled alarm for tomorrow: ${calendar.time}")
}
}
}
} catch (e: Exception) {
Log.e("ShiftEndReceiver", "Error rescheduling alarm: ${e.message}", e)
}
} catch (e: Exception) {
Log.e("ShiftEndReceiver", "❌ Error handling shift end: ${e.message}", e)
e.printStackTrace()
} finally {
// ✅ CRITICAL: Release WakeLock in finally block
try {
if (wakeLock.isHeld) {
wakeLock.release()
Log.d("ShiftEndReceiver", "🔓 WakeLock released")
}
} catch (e: Exception) {
Log.e("ShiftEndReceiver", "Error releasing WakeLock: ${e.message}")
}
}
}.start()
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
storePassword=123456789 storePassword=W8UzA5NVaDTvix79rekmn8834m81tcKX
keyPassword=123456789 keyPassword=W8UzA5NVaDTvix79rekmn8834m81tcKX
keyAlias= doormile keyAlias=doormile
storeFile=doormilerider-keystore.jks storeFile=doormilerider-keystore.jks

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -3,15 +3,15 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_tts/flutter_tts.dart'; import 'package:flutter_tts/flutter_tts.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:audioplayers/audioplayers.dart'; import 'package:audioplayers/audioplayers.dart';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:miler/providers/pickuplog/pickuplog_provider.dart'; import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/data/device_telemetry.dart'; import 'package:miler/data/device_telemetry.dart';
import 'package:miler/providers/notifications/notificationservce.dart'; import 'package:miler/providers/notifications/notificationservce.dart';
import 'package:miler/views/helpers/constants/apiconstants.dart';
import 'package:miler/utils/kalman_filter.dart'; import 'package:miler/utils/kalman_filter.dart';
/// Background service for managing active pickup logs /// Background service for managing active pickup logs
@@ -20,7 +20,6 @@ class BackgroundCollectionLog {
static DateTime? _lastUpdateTime; static DateTime? _lastUpdateTime;
static final CreatePickupLogProvider _logProvider = CreatePickupLogProvider(); static final CreatePickupLogProvider _logProvider = CreatePickupLogProvider();
static final http.Client _httpClient = http.Client();
static final FlutterTts _tts = FlutterTts(); static final FlutterTts _tts = FlutterTts();
static final AudioPlayer _proximityPlayer = AudioPlayer(); static final AudioPlayer _proximityPlayer = AudioPlayer();
@@ -60,38 +59,39 @@ class BackgroundCollectionLog {
final now = DateTime.now(); final now = DateTime.now();
final today = '${now.year}-${_pad(now.month)}-${_pad(now.day)}'; final today = '${now.year}-${_pad(now.month)}-${_pad(now.day)}';
// Build API URL using v1 endpoint (getpickup) // ── This asked the pre-v1 backend, with the wrong question ──
final bool isLive = ApiConstants.mainRoute == 'live'; //
final baseUrl = isLive // It built a `pickups/getpickups` URL on the retired backend by hand
? ApiConstants.currentPickupLive // and fetched it with a bare `_httpClient.get` — **no bearer token**, and
: ApiConstants.currentPickupDev; // `userid` in the query string, so the rider's day was readable by id
// from an unauthenticated call.
final uri = Uri.parse(baseUrl).replace( //
queryParameters: { // It could never have worked anyway. The filter below looked for
'userid': userId.toString(), // `orderstatus == 'active'`, and `active` is not a value this app's own
'fromdate': today, // vocabulary contains: [ApiConfig.legacyStatusFromNew] maps the v1
'todate': today, // statuses to `assigned` / `accepted` / `arrived` / `picked`, and never to
't': DateTime.now().millisecondsSinceEpoch.toString(), // `active`. So on every tick this returned 0 items and fell through to
}, // the shift-end check — which is exactly what the device log shows.
); //
// `GET /miler/bookings` is the v1 question, asked with the token, about
debugPrint('[ACTIVE_COLLECTION_LOG][BG] Fetching Pickup: $uri'); // the rider the token names. The filter is the real vocabulary: work the
// rider has taken on and not yet collected.
// Fetch pickup from API using v1 endpoint final res = await MilerApi.bookings(date: today);
final pickups = await _fetchPickupsFromApi(uri); final pickups = res.list;
debugPrint( debugPrint(
'[ACTIVE_COLLECTION_LOG][BG] API returned ${pickups.length} items', '[ACTIVE_COLLECTION_LOG][BG] /miler/bookings -> ${res.status}, '
'${pickups.length} items',
); );
// Filter for active bookings only const inProgress = {'accepted', 'arrived'};
final activeOrders = pickups.whereType<Map<String, dynamic>>().where(( final activeOrders = pickups.whereType<Map<String, dynamic>>().where((
Booking, Booking,
) { ) {
final status = (Booking['orderstatus']?.toString().toLowerCase() ?? '') final status = ApiConfig.legacyStatusFromNew(
.trim(); (Booking['status'] ?? Booking['orderstatus'])?.toString(),
final isActive = status == 'active'; );
return isActive; return inProgress.contains(status);
}).toList(); }).toList();
if (activeOrders.isEmpty) { if (activeOrders.isEmpty) {
@@ -189,7 +189,9 @@ class BackgroundCollectionLog {
// so every per-stop breadcrumb reached the console with a position and // so every per-stop breadcrumb reached the console with a position and
// nothing to explain a gap in the trail. Read from the same place the // nothing to explain a gap in the trail. Read from the same place the
// rider heartbeat reads it. See [DeviceTelemetry]. // rider heartbeat reads it. See [DeviceTelemetry].
final telemetry = await DeviceTelemetry.read(locationService: coords['status']); final telemetry = await DeviceTelemetry.read(
locationService: coords['status'],
);
// Build payload with coordinates and timestamp // Build payload with coordinates and timestamp
payloadWithCoords = <String, dynamic>{ payloadWithCoords = <String, dynamic>{
@@ -211,11 +213,6 @@ class BackgroundCollectionLog {
'logstatus': await _resolveLogStatus(orderId, coords), 'logstatus': await _resolveLogStatus(orderId, coords),
}; };
// Determine API endpoint
final url = ApiConstants.mainRoute == 'live'
? ApiConstants.createPickupLogLive
: ApiConstants.createPickupLogDev;
// Post the log // Post the log
debugPrint('[ACTIVE_COLLECTION_LOG][BG] Posting pickup log for $orderId'); debugPrint('[ACTIVE_COLLECTION_LOG][BG] Posting pickup log for $orderId');
@@ -427,33 +424,6 @@ class BackgroundCollectionLog {
} }
/// Fetch pickup from API using v1 endpoint /// Fetch pickup from API using v1 endpoint
static Future<List<dynamic>> _fetchPickupsFromApi(Uri uri) async {
try {
final res = await _httpClient
.get(uri)
.timeout(const Duration(seconds: 10));
if (res.statusCode >= 200 && res.statusCode < 300) {
final decoded = json.decode(res.body);
final data = decoded is Map<String, dynamic>
? (decoded['details'] ?? decoded['data'] ?? decoded)
: decoded;
if (data is List) {
return data;
}
if (data is Map && data['items'] is List) {
return data['items'] as List;
}
return [];
}
return [];
} catch (e) {
return [];
}
}
/// Helper to pad numbers with leading zero /// Helper to pad numbers with leading zero
static String _pad(int n) => n.toString().padLeft(2, '0'); static String _pad(int n) => n.toString().padLeft(2, '0');
@@ -533,10 +503,6 @@ class BackgroundCollectionLog {
final List<String> remaining = []; final List<String> remaining = [];
bool anySuccess = false; bool anySuccess = false;
final url = ApiConstants.mainRoute == 'live'
? ApiConstants.createPickupLogLive
: ApiConstants.createPickupLogDev;
for (final itemStr in queue) { for (final itemStr in queue) {
try { try {
final Map<String, dynamic> item = jsonDecode(itemStr); final Map<String, dynamic> item = jsonDecode(itemStr);
@@ -761,14 +727,18 @@ class BackgroundCollectionLog {
'[AUTO_SHIFT_END] 🚀 Initiating auto-break and offline sequence...', '[AUTO_SHIFT_END] 🚀 Initiating auto-break and offline sequence...',
); );
// 1. Get Required IDs // ── The four ids and the fix are no longer this function's business ──
//
// `userid`, `partnerid`, `shiftid`, `logid` and a position were assembled
// here to fill a legacy break-log body. On v1 the break and the duty end
// are both keyed on the **token**: `POST /miler/breaks/start` opens a
// break for whoever the session names, and `PUT /miler/duty/end` closes
// whichever duty log is open for them. Sending an id would not make it
// more correct; it is what let the old call be made unauthenticated.
//
// `userid` is still read, as the one guard worth keeping: no signed-in
// rider, nothing to end.
final int userid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; final int userid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0;
final int partnerid =
prefs.getInt('partnerid') ?? prefs.getInt('partnerId') ?? 0;
final int shiftid =
prefs.getInt('shiftid') ?? prefs.getInt('shiftId') ?? 0;
final int logid = prefs.getInt('logid') ?? prefs.getInt('logId') ?? 0;
if (userid == 0) { if (userid == 0) {
debugPrint( debugPrint(
'[AUTO_SHIFT_END] ❌ Missing userid, cannot create break log', '[AUTO_SHIFT_END] ❌ Missing userid, cannot create break log',
@@ -776,61 +746,28 @@ class BackgroundCollectionLog {
return; return;
} }
// 2. Get Location
final Map<String, String>? coords = await _getCoordinatesWithFallback();
final String lat = coords?['lat'] ?? '0';
final String lng = coords?['lng'] ?? '0';
// 3. Prepare Break Log Payload
final now = DateTime.now(); final now = DateTime.now();
final int localBreakId = final int localBreakId =
(DateTime.now().millisecondsSinceEpoch % 900) + 100; // Random-ish ID (DateTime.now().millisecondsSinceEpoch % 900) + 100; // Random-ish ID
final String breakdate =
'${now.year}-${_pad(now.month)}-${_pad(now.day)} ${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}';
final String breakstart = final String breakstart =
'${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}'; '${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}';
final payload = <String, dynamic>{ // 4. Close the shift with a break, on v1.
"breakid": localBreakId, //
"logid": logid, // This hand-posted the retired backend's `partners/createbreaklog`
"breakdate": breakdate, // with a bare `_httpClient.post` and no bearer
"userid": userid, // token — an unauthenticated write keyed on a `userid` in the
"partnerid": partnerid, // body. `POST /miler/breaks/start` is the v1 endpoint, and
"shiftid": shiftid, // [MilerApi] attaches the session.
"breakstart": breakstart, debugPrint('[AUTO_SHIFT_END] Creating break log (v1)');
"breakend": "",
"breakhours": 0.0,
"latitude": lat,
"longitude": lng,
};
// 4. Call API to Create Break
final url = ApiConstants.mainRoute == 'live'
? ApiConstants.createBreakRiderLogLive
: ApiConstants.createBreakRiderLogDev;
debugPrint('[AUTO_SHIFT_END] Creating break log: $url');
// We use a separate provider instance or http call if needed,
// but _logProvider is for pickup logs. We need a generic post or use http directly.
// Since we don't have BreakRiderLogProvider here, we'll use http directly for simplicity and isolation.
try { try {
final response = await _httpClient final res = await MilerApi.startBreak('Shift_End');
.post(
Uri.parse(url),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(payload),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode >= 200 && response.statusCode < 300) { if (res.ok) {
debugPrint('[AUTO_SHIFT_END] ✅ Break log created successfully'); debugPrint('[AUTO_SHIFT_END] ✅ Break log created successfully');
// Parse response to get server break ID if needed, but mainly we just need to go offline final serverBreakId = res.map['breaklogid'] ?? res.map['breakid'];
final body = jsonDecode(response.body);
final det = (body['details'] is Map) ? body['details'] : body;
final serverBreakId = det['breakid'];
if (serverBreakId != null) { if (serverBreakId != null) {
await prefs.setInt( await prefs.setInt(
@@ -842,7 +779,7 @@ class BackgroundCollectionLog {
await prefs.setInt('break_start_epoch', now.millisecondsSinceEpoch); await prefs.setInt('break_start_epoch', now.millisecondsSinceEpoch);
} else { } else {
debugPrint( debugPrint(
'[AUTO_SHIFT_END] ⚠️ Failed to create break log: ${response.statusCode}', '[AUTO_SHIFT_END] ⚠️ Failed to create break log: ${res.status}',
); );
} }
} catch (e) { } catch (e) {
@@ -854,27 +791,15 @@ class BackgroundCollectionLog {
await prefs.setInt('onduty', 0); await prefs.setInt('onduty', 0);
await prefs.setBool('online', false); await prefs.setBool('online', false);
// 6. Update Rider Log (Set Duty = 0) // 6. End duty, on v1.
// We should also update the main rider log to say onduty=0 //
final updateUrl = ApiConstants.mainRoute == 'live' // Was an unauthenticated POST of `{userid, onduty: 0}` to the
? ApiConstants.updateRiderLogLive // legacy `updateriderlog`. `PUT /miler/duty/end` closes whichever
: ApiConstants.updateRiderLogDev; // duty log is open for the token's rider, which is the same fact
// without the id or the open door.
final updatePayload = {
"userid": userid,
"onduty": 0,
"latitude": lat,
"longitude": lng,
};
try { try {
await _httpClient await MilerApi.endDuty();
.post(
Uri.parse(updateUrl),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(updatePayload),
)
.timeout(const Duration(seconds: 5));
debugPrint('[AUTO_SHIFT_END] ✅ Rider status updated to Offline'); debugPrint('[AUTO_SHIFT_END] ✅ Rider status updated to Offline');
} catch (_) {} } catch (_) {}

View File

@@ -6,11 +6,11 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/views/helpers/constants/apiconstants.dart';
import 'package:miler/providers/Riderlog/riderlog_provider.dart'; import 'package:miler/providers/Riderlog/riderlog_provider.dart';
import 'package:miler/background/backgroundservice.dart'; import 'package:miler/background/backgroundservice.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:miler/data/device_telemetry.dart'; import 'package:miler/data/device_telemetry.dart';
import 'package:miler/data/heartbeat.dart';
import 'package:miler/utils/kalman_filter.dart'; import 'package:miler/utils/kalman_filter.dart';
import 'package:miler/utils/mqtt_service.dart'; import 'package:miler/utils/mqtt_service.dart';
import 'package:miler/views/helpers/constants/mqtt_constants.dart'; import 'package:miler/views/helpers/constants/mqtt_constants.dart';
@@ -149,7 +149,7 @@ class _BackgroundRiderLog {
// ✅ Check if there are active pickup to set status // ✅ Check if there are active pickup to set status
final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false; final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false;
final String riderStatus = hasActivePickups ? 'active' : 'idle'; final String riderStatus = heartbeatStatus(hasActiveWork: hasActivePickups);
final now = DateTime.now(); final now = DateTime.now();
final iso = _formatDateTimeFull(now); final iso = _formatDateTimeFull(now);
@@ -226,9 +226,6 @@ class _BackgroundRiderLog {
payload['lastname'] = lastName.trim(); payload['lastname'] = lastName.trim();
} }
final base = ApiConstants.mainRoute == 'live'
? ApiConstants.createRiderLogLive
: ApiConstants.createRiderLogDev;
final provider = CreateRiderLogProvider(); final provider = CreateRiderLogProvider();
final resp = await provider.createRiderLog(payload); final resp = await provider.createRiderLog(payload);
@@ -304,7 +301,7 @@ class _BackgroundRiderLog {
// 5. Lane: Status // 5. Lane: Status
mqttService.updateStatus( mqttService.updateStatus(
riderStatus == 'active' ? 'Active' : MqttConstants.statusOnline, riderStatus == 'On_Pickup' ? 'Active' : MqttConstants.statusOnline,
); );
// 6. Lane: Periodic Log (Comprehensive Snapshot) // 6. Lane: Periodic Log (Comprehensive Snapshot)

View File

@@ -9,7 +9,6 @@ import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/providers/pickuplog/pickuplog_provider.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:miler/background/foreground_service.dart' as fg;
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';

View File

@@ -12,7 +12,6 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/accepted_store.dart'; import 'package:miler/data/accepted_store.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:miler/views/helpers/constants/apiconstants.dart';
import 'package:miler/providers/pickuplog/pickuplog_provider.dart'; import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
import 'dart:io'; import 'dart:io';
import 'package:minio/minio.dart'; import 'package:minio/minio.dart';
@@ -1203,12 +1202,6 @@ class PickupsController extends GetxController
return "$y-$m-$d $hh:$mm:$ss"; return "$y-$m-$d $hh:$mm:$ss";
} }
String _resolveUpdateUrl() {
return ApiConstants.mainRoute == 'live'
? ApiConstants.updatePickupLive
: ApiConstants.updatePickupDev;
}
// ---------------- SharedPrefs helpers ---------------- // ---------------- SharedPrefs helpers ----------------
Future<void> setNotificationSent(bool value) async { Future<void> setNotificationSent(bool value) async {
try { try {

View File

@@ -5,7 +5,7 @@ import 'package:miler/data/miler_api.dart';
/// ///
/// ── Two things this used to do and no longer does ── /// ── Two things this used to do and no longer does ──
/// ///
/// It called a second, legacy backend (`jupiter.doormile.app/.../ /// It called a second, legacy backend (the retired `.../
/// getuserbonussummary`) when the new-API flag was off, and it filled an empty /// 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 /// 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 /// backend now, and a points balance is a number about somebody's pay — the

View File

@@ -1,7 +1,6 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:miler/views/helpers/constants/apiconstants.dart';
import 'package:miler/Models/riders/riders_models.dart'; import 'package:miler/Models/riders/riders_models.dart';
import 'package:miler/providers/Riderlog/riderlog_provider.dart'; import 'package:miler/providers/Riderlog/riderlog_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -16,6 +15,7 @@ import 'package:miler/background/foreground_service.dart' as fg;
import 'package:miler/utils/kalman_filter.dart'; import 'package:miler/utils/kalman_filter.dart';
import 'package:miler/utils/mqtt_service.dart'; import 'package:miler/utils/mqtt_service.dart';
import 'package:miler/data/device_telemetry.dart'; import 'package:miler/data/device_telemetry.dart';
import 'package:miler/data/heartbeat.dart';
import 'package:miler/views/helpers/constants/mqtt_constants.dart'; import 'package:miler/views/helpers/constants/mqtt_constants.dart';
import 'package:battery_plus/battery_plus.dart'; import 'package:battery_plus/battery_plus.dart';
import 'package:miler/controllers/connectivity_mixin.dart'; import 'package:miler/controllers/connectivity_mixin.dart';
@@ -235,7 +235,7 @@ class RiderLogController extends GetxController
// ✅ Check if there are active pickup to set status // ✅ Check if there are active pickup to set status
final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false; final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false;
final String riderStatus = hasActivePickups ? 'active' : 'idle'; final String riderStatus = heartbeatStatus(hasActiveWork: hasActivePickups);
final String orderId = prefs.getString('current_riding_order_id') ?? ''; final String orderId = prefs.getString('current_riding_order_id') ?? '';
debugPrint( debugPrint(
'[RIDERLOG][CREATE LOGIN NOW] Active pickup: $hasActivePickups -> status: $riderStatus', '[RIDERLOG][CREATE LOGIN NOW] Active pickup: $hasActivePickups -> status: $riderStatus',
@@ -315,7 +315,7 @@ class RiderLogController extends GetxController
// 1. Lane: Status // 1. Lane: Status
mqttService.updateStatus( mqttService.updateStatus(
riderStatus == 'active' ? 'Active' : MqttConstants.statusOnline, riderStatus == 'On_Pickup' ? 'Active' : MqttConstants.statusOnline,
); );
// 2. Lane: Profile (Send only if significantly changed or first time) // 2. Lane: Profile (Send only if significantly changed or first time)
@@ -448,7 +448,7 @@ class RiderLogController extends GetxController
// Check status for offline fallback too // Check status for offline fallback too
final bool hasActivePickups = final bool hasActivePickups =
prefs.getBool('has_live_pickup') ?? false; prefs.getBool('has_live_pickup') ?? false;
final String riderStatus = hasActivePickups ? 'active' : 'idle'; final String riderStatus = heartbeatStatus(hasActiveWork: hasActivePickups);
final Map<String, dynamic> payload = { final Map<String, dynamic> payload = {
...baseTemplate, ...baseTemplate,
@@ -520,10 +520,19 @@ class RiderLogController extends GetxController
await prefs.setInt('onduty', on ? 1 : 0); await prefs.setInt('onduty', on ? 1 : 0);
if (on) { if (on) {
await createLoginNowV2(); await createLoginNowV2();
final int interval = prefs.getInt('logseconds') ?? 0; // ── Going on duty always starts the heartbeat ──
if (interval > 0) { //
startAutoCreateLoginLoop(seconds: interval); // This was gated on `interval > 0`, reading a stored zero as a
} // decision not to beat. The v1 login has no `logseconds` to store, so
// the gate was shut on every rider: duty went on, one log was posted
// by the call above, and nothing followed it — no telemetry trail and
// no location writes for the rest of the shift.
//
// There is no cadence at which a rider on duty should not report, so
// there is no branch here any more. [resolveLogSeconds] supplies the
// floor when the hub has not set one.
final int interval = resolveLogSeconds(prefs.getInt('logseconds'));
startAutoCreateLoginLoop(seconds: interval);
// Ensure foreground logging notification is started when going on-duty // Ensure foreground logging notification is started when going on-duty
try { try {
@@ -590,15 +599,45 @@ class RiderLogController extends GetxController
} }
} }
/// Whether the periodic heartbeat is currently running on the stream path.
///
/// ── Why a test needs to be able to see this ──
///
/// The `logseconds` bug was invisible from outside the class. Duty went on,
/// the API accepted it, one log was posted by the immediate call — and the
/// loop that was supposed to follow it simply never started. Nothing threw,
/// nothing logged an error, and the only symptom was a console panel that had
/// been blank for so long it read as a backend problem.
///
/// Android normally runs the heartbeat as a foreground service instead, and
/// leaves this null; the test host is never Android, so the stream path is
/// the one under test. See [startAutoCreateLoginLoop].
@visibleForTesting
bool get autoLoopRunning => _autoLoginSubscription != null;
// Start periodic createRiderLog calls based on seconds (or prefs 'logseconds') // Start periodic createRiderLog calls based on seconds (or prefs 'logseconds')
// ✅ CRITICAL: When there are active pickup, use 30 seconds (same as pickup logs) // ✅ CRITICAL: When there are active pickup, use 30 seconds (same as pickup logs)
// Otherwise, use the configured logseconds interval // Otherwise, use the configured logseconds interval
void startAutoCreateLoginLoop({int? seconds}) async { /// `Future<void>`, not `void`. It was fire-and-forget, which meant no caller
/// could wait for the loop to actually be up — and the one place that most
/// wanted to, [setOnDuty], had no way to tell whether the heartbeat it had
/// just asked for existed. Every existing call site ignores the future and is
/// unchanged by this.
Future<void> startAutoCreateLoginLoop({int? seconds}) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
// ✅ Check if there are active pickup - if yes, use 30 seconds (same as pickup logs) // ✅ Check if there are active pickup - if yes, use 30 seconds (same as pickup logs)
final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false; final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false;
final int baseInterval = seconds ?? (prefs.getInt('logseconds') ?? 0); // ── The configured cadence, or the floor under it ──
//
// `?? 0` here was the second half of the same bug as the one in
// [setOnDuty]: a caller that passes no `seconds` — the app-resume path in
// `main.dart` is one — fell through to a stored zero and then returned at
// the `interval <= 0` guard below, so the loop the resume was trying to
// revive silently did not start. See [resolveLogSeconds].
final int baseInterval = resolveLogSeconds(
seconds ?? prefs.getInt('logseconds'),
);
// When there are active pickup, post rider logs every 30 seconds (matching pickup logs) // When there are active pickup, post rider logs every 30 seconds (matching pickup logs)
// Otherwise, use the configured interval // Otherwise, use the configured interval
@@ -610,12 +649,20 @@ class RiderLogController extends GetxController
); );
} }
await stopAutoCreateLoginLoop(); // Cancel existing subscription // Stop before start, which is what keeps this method safe to call from the
// three places that do — going on duty, resuming the app, and a pickup
// going live. Whichever arrives last wins, and there is never a second
// loop: the subscription is cancelled and the foreground service stopped
// before either is created again.
await stopAutoCreateLoginLoop();
// Attempt to flush offline logs on loop start // Attempt to flush offline logs on loop start
flushOfflineLogs(); flushOfflineLogs();
if (interval <= 0) return; // No `interval <= 0` guard. [resolveLogSeconds] cannot return one, and the
// guard that used to be here is what turned a missing `logseconds` into a
// rider who never reported. Duty state decides whether the loop runs; the
// cadence only decides how often.
final int onduty = prefs.getInt('onduty') ?? 0; final int onduty = prefs.getInt('onduty') ?? 0;
if (onduty != 1) { if (onduty != 1) {
debugPrint('[RIDERLOG][AUTO LOOP] Not starting - onduty=$onduty'); debugPrint('[RIDERLOG][AUTO LOOP] Not starting - onduty=$onduty');
@@ -1049,9 +1096,6 @@ class RiderLogController extends GetxController
final breakhoursDouble = duration.inSeconds / 3600.0; final breakhoursDouble = duration.inSeconds / 3600.0;
// Debug logs for terminal visibility // Debug logs for terminal visibility
debugPrint(
'[BREAK][UPDATE] URL: ${ApiConstants.mainRoute == 'live' ? ApiConstants.updateBreakRiderLogLive : ApiConstants.updateBreakRiderLogDev}',
);
debugPrint( debugPrint(
'[BREAK][UPDATE] Fields: breakid=$breakid, logid=$logid, userid=$userid, partnerid=$partnerid, shiftid=$shiftid, breakend=$breakend, breakhours=$breakhoursDouble, lat=$latitude, lng=$longitude', '[BREAK][UPDATE] Fields: breakid=$breakid, logid=$logid, userid=$userid, partnerid=$partnerid, shiftid=$shiftid, breakend=$breakend, breakhours=$breakhoursDouble, lat=$latitude, lng=$longitude',
); );
@@ -1107,7 +1151,7 @@ class RiderLogController extends GetxController
// ✅ Check if there are active pickup to set status // ✅ Check if there are active pickup to set status
final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false; final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false;
final String riderStatus = hasActivePickups ? 'active' : 'idle'; final String riderStatus = heartbeatStatus(hasActiveWork: hasActivePickups);
// Resolve rider display name (username) from prefs to include in log // Resolve rider display name (username) from prefs to include in log
String? username = prefs.getString('username'); String? username = prefs.getString('username');

View File

@@ -13,7 +13,7 @@ import 'package:shared_preferences/shared_preferences.dart';
/// ── The flag is gone ── /// ── The flag is gone ──
/// ///
/// This class used to carry `useNewApi`, a `--dart-define` switch between the /// This class used to carry `useNewApi`, a `--dart-define` switch between the
/// v1 backend and a legacy one (`jupiter.doormile.app` / `queue.workolik.com`). /// v1 backend and a legacy one, on two now-retired hosts.
/// Every provider branched on it, so the app shipped two implementations of /// Every provider branched on it, so the app shipped two implementations of
/// every call and only one of them was ever exercised. /// every call and only one of them was ever exercised.
/// ///

View File

@@ -46,10 +46,11 @@ class DeviceTelemetry {
/// problem as a rider at 12% and riding. /// problem as a rider at 12% and riding.
final bool? isCharging; final bool? isCharging;
/// `wifi`, `mobile`, `none` … as the platform names it. /// `WiFi`, `4G` or `none` — the contract's vocabulary, not the plugin's.
/// See [_connectionName].
final String? connection; final String? connection;
/// `enabled`, `disabled`, `denied`, `denied_forever`, `unknown`. /// `enabled` or `disabled`. See [_locationServiceName].
/// ///
/// The one field here that is about a *choice the rider made*, which is why /// The one field here that is about a *choice the rider made*, which is why
/// it is worth a column of its own on the console: a disabled location /// it is worth a column of its own on the console: a disabled location
@@ -86,9 +87,7 @@ class DeviceTelemetry {
String? connection; String? connection;
try { try {
final result = await Connectivity().checkConnectivity(); final result = await Connectivity().checkConnectivity();
connection = result.isNotEmpty connection = _connectionName(result);
? result.first.toString().split('.').last
: 'none';
} catch (e) { } catch (e) {
debugPrint('[TELEMETRY] connectivity unavailable: $e'); debugPrint('[TELEMETRY] connectivity unavailable: $e');
} }
@@ -104,6 +103,7 @@ class DeviceTelemetry {
service = null; service = null;
} }
} }
service = _locationServiceName(service);
return DeviceTelemetry( return DeviceTelemetry(
battery: level, battery: level,
@@ -113,6 +113,48 @@ class DeviceTelemetry {
); );
} }
/// `WiFi` / `4G` / `none`, which is the vocabulary `/miler/logs` documents.
///
/// ── The plugin's own spelling is not the contract's ──
///
/// This was `result.first.toString().split('.').last`, which is the *enum
/// constant* — `wifi`, `mobile`, `ethernet`, `vpn`, `bluetooth`, `other`.
/// Two of those happen to look right in lower case and the rest do not
/// appear in the contract at all, so the console's Connection column was
/// rendering whatever `connectivity_plus` happened to call the transport
/// this release.
///
/// `first` was also the wrong pick: the list is every active transport, so a
/// phone on Wi-Fi with mobile data up could report either depending on the
/// order the platform returned them. Wi-Fi wins where both are present,
/// because it is the one that explains a rider whose data has run out.
static String _connectionName(List<ConnectivityResult> results) {
if (results.isEmpty) return 'none';
if (results.contains(ConnectivityResult.wifi)) return 'WiFi';
if (results.contains(ConnectivityResult.mobile)) return '4G';
if (results.contains(ConnectivityResult.ethernet)) return 'ethernet';
if (results.every((r) => r == ConnectivityResult.none)) return 'none';
return 'other';
}
/// `enabled` or `disabled` — the two values the contract names.
///
/// The richer answers the fix lookup produces — `denied`, `denied_forever`,
/// `unknown` — are more useful to a dispatcher, and they are not what this
/// field accepts. On a backend that validates its enums (and this one does
/// silently: see the `Break` / `On_Break` note in [MilerApi]) an unrecognised
/// value risks the whole row, which costs the battery and the connection
/// alongside it.
///
/// So anything that is not `enabled` is `disabled` here, which is the fact
/// the column exists to report — this rider's location is not reaching us.
/// The distinction between *off* and *denied* is not lost: it still rides on
/// the MQTT `location_turned_off` alert as `error_type`.
static String? _locationServiceName(String? raw) {
if (raw == null || raw.isEmpty) return null;
return raw == 'enabled' ? 'enabled' : 'disabled';
}
/// The heartbeat payload's own field names, ready to spread into it. /// The heartbeat payload's own field names, ready to spread into it.
/// ///
/// Only what was actually read: an absent key is a field the console draws as /// Only what was actually read: an absent key is a field the console draws as

89
lib/data/heartbeat.dart Normal file
View File

@@ -0,0 +1,89 @@
/// ─────────────────────────────────────────────────────────────────────────
/// HOW OFTEN THE RIDER LOG BEATS
///
/// ── Why this constant exists ──
///
/// The heartbeat's cadence came from one place: `logseconds`, a field the
/// **legacy** login returned and the app persisted at sign-in. The v1 contract
/// at `api.doormile.com` does not send it — `POST /miler/verify-pin` answers
/// with `{success, token, user:{…, profile:{…}}}` and nothing about logging
/// cadence — so the adapter in `AuthProvider` had no value to map and wrote the
/// `?? 0` fallback into prefs.
///
/// Zero was then read as an instruction rather than as an absence. Both call
/// sites treated it as "do not beat":
///
/// • `RiderLogController.setOnDuty` started the loop only `if (interval > 0)`.
/// • `startAutoCreateLoginLoop` computed `baseInterval = 0` and returned at
/// `if (interval <= 0)`.
///
/// So on every v1 deployment the periodic loop never started. What went with
/// it was not just telemetry: `_heartbeat()` in the rider-log provider is the
/// one place that writes **both** `POST /miler/logs` (the trail the console's
/// Battery, Charging, Connection, GPS Accuracy and Location Service columns are
/// read from) and `PUT /miler/location` (the Redis geo-index dispatch searches
/// to find a rider at all). A rider on duty was reporting neither, and the
/// console drew an em dash against a phone that was measuring all of it
/// correctly — see [DeviceTelemetry], which was never the problem.
///
/// The only reason it was not total silence is that a live pickup forces the
/// interval to 30 by a separate path, so a rider mid-collection beat and a
/// rider between stops did not.
///
/// ── Why 30 seconds ──
///
/// It is the cadence the rest of the app already assumes when nobody has told
/// it otherwise: the pickup log's own `_getLogInterval` falls back to 30, and
/// both controllers hard-code 30 for the live-pickup case. Matching it means a
/// rider's location trail has one shape rather than two, and a hub that later
/// starts sending `logseconds` still wins — this is a floor under a missing
/// answer, not a replacement for a real one.
/// ─────────────────────────────────────────────────────────────────────────
library;
/// Seconds between rider-log heartbeats when the backend has not said.
const int kDefaultLogSeconds = 30;
/// The heartbeat interval to actually use, given whatever the backend said.
///
/// A positive value is honoured exactly. `null`, a zero, a negative, and a
/// string that is none of those all mean *the backend did not answer*, and the
/// answer to that is [kDefaultLogSeconds] — never zero, because zero is what
/// stopped the heartbeat starting in the first place.
///
/// Accepts an [Object] rather than an `int?` because the value arrives from two
/// different shapes: `prefs.getInt('logseconds')` gives an `int?`, while the
/// login envelope's `details['logseconds']` is untyped JSON and has been seen
/// as both a number and a string.
int resolveLogSeconds(Object? configured) {
final int? parsed = switch (configured) {
final int n => n,
final num n => n.toInt(),
final String s => int.tryParse(s.trim()),
_ => null,
};
return (parsed != null && parsed > 0) ? parsed : kDefaultLogSeconds;
}
/// The `status` a heartbeat reports, given whether the rider has live work.
///
/// ── `active` and `idle` are not words this contract knows ──
///
/// Four call sites built this string inline as `hasActivePickups ? 'active' :
/// 'idle'`, and neither value appears anywhere in the API. `status` on
/// `POST /miler/logs` takes an **availability** value — the set
/// [MilerApi.availabilityStatuses] lists, and the same set
/// `PUT /miler/availability` validates:
///
/// Offline · Available · Assigned · On_Pickup · At_Customer ·
/// Picked_Up · On_Delivery · Break · Blocked
///
/// This backend rejects an unrecognised enum **silently** — the `Break` /
/// `On_Break` note in [MilerApi] records the last time the obvious guess was
/// quietly dropped — so a bad `status` risks the whole row, and takes the
/// battery, the connection and the location reading down with it.
///
/// A rider working a counter is `On_Pickup`; a rider between stops is
/// `Available`. Both are values the console already renders.
String heartbeatStatus({required bool hasActiveWork}) =>
hasActiveWork ? 'On_Pickup' : 'Available';

View File

@@ -135,9 +135,32 @@ class MilerApi {
/// Telemetry wants strings for values that are numbers everywhere else. /// Telemetry wants strings for values that are numbers everywhere else.
static String _str(dynamic v) => v == null ? '' : v.toString(); static String _str(dynamic v) => v == null ? '' : v.toString();
/// `YYYY-MM-DD HH:MM:SS`, IST wall-clock, for telemetry `logdate`. /// `YYYY-MM-DD HH:MM:SS`, the phone's **local wall clock**, for `logdate`.
///
/// ── This was briefly changed to UTC, and that was wrong ──
///
/// The reasoning looked sound: the field carries no zone suffix, so one side
/// has to name the convention, and UTC is the usual answer. It is not the
/// answer here. The Doormile backend's read range — `DBNow` / `DBToday` — and
/// the database itself run on **IST wall-clock**, so a UTC stamp from an
/// Indian handset lands 5h30m *behind* the window the console asks for, and
/// after 18:30 local it files the evening's work under the previous day.
///
/// Confirmed against the live backend on 28 Aug 2026, after the UTC change
/// had already shipped to a test build. It went unnoticed there because that
/// handset's own clock was running on UTC, so local and UTC agreed and the
/// rows landed in the window anyway.
///
/// So: local, and the phone's timezone is assumed to be the fleet's. That is
/// true for every Doormile Miler today. The durable fix is an offset on the
/// wire — an ISO-8601 stamp the backend parses with its zone — and until the
/// contract carries one, this is the convention both sides have agreed.
static String logStamp([DateTime? at]) { static String logStamp([DateTime? at]) {
final t = at ?? DateTime.now(); // `.toLocal()` because a caller can hand this a UTC `DateTime` — the
// consignment breadcrumb does — and reading `.hour` off one of those
// renders the UTC wall clock, which is the bug this method just came back
// from. A local `DateTime` passes through unchanged.
final t = (at ?? DateTime.now()).toLocal();
String p(int n) => n.toString().padLeft(2, '0'); String p(int n) => n.toString().padLeft(2, '0');
return '${t.year}-${p(t.month)}-${p(t.day)} ' return '${t.year}-${p(t.month)}-${p(t.day)} '
'${p(t.hour)}:${p(t.minute)}:${p(t.second)}'; '${p(t.hour)}:${p(t.minute)}:${p(t.second)}';

View File

@@ -12,10 +12,9 @@ import 'dart:io';
/// day's cash figures without a word. /// day's cash figures without a word.
/// ///
/// It is the kind of thing that gets added once to get past a staging server /// It is the kind of thing that gets added once to get past a staging server
/// and then ships. Both live hosts were checked before removing it and both /// and then ships. The live host was checked before removing it:
/// present valid, publicly-trusted certificates — `api.doormile.com` and /// `api.doormile.com` verifies strictly under TLS 1.3 against a publicly
/// `queue.workolik.com` verify strictly under TLS 1.3 against Let's Encrypt. /// trusted certificate. Nothing needed the bypass.
/// Nothing needed the bypass.
/// ///
/// ── What is left, and why the class is still here ── /// ── What is left, and why the class is still here ──
/// ///

View File

@@ -11,7 +11,7 @@ import 'package:miler/data/miler_api.dart';
/// ///
/// A `_buildSslBypassClient()` that returned an `IOClient` whose /// A `_buildSslBypassClient()` that returned an `IOClient` whose
/// `badCertificateCallback` returned `true` for every certificate, pinned a /// `badCertificateCallback` returned `true` for every certificate, pinned a
/// hardcoded IP for `queue.workolik.com`, and hand-rolled its own /// hardcoded IP for the retired backend's host, and hand-rolled its own
/// `SecureSocket` upgrade. It existed to work around a carrier's broken DNS on /// `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 /// 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 /// could be read and rewritten by anything on the path, including a rider's GPS
@@ -33,6 +33,132 @@ String _s(dynamic v) => v == null ? '' : v.toString();
double? _d(dynamic v) => double.tryParse(_s(v)); double? _d(dynamic v) => double.tryParse(_s(v));
/// ─────────────────────────────────────────────────────────────────────────
/// WHY THE CONSOLE'S DEVICE PANEL WAS EMPTY
///
/// Not the telemetry. [DeviceTelemetry] reads the handset correctly, its
/// payload carries every field the console draws, and every caller spreads it
/// into the heartbeat. The reading was right and it never left the phone.
///
/// [_heartbeat] is the only thing that posts `POST /miler/logs`, and it is also
/// the only thing that writes `PUT /miler/location`. Both were unreachable,
/// because the two entry points below gate on one locally cached number:
///
/// ```
/// if ((prefs.getInt('dutylogid') ?? 0) <= 0) return _startDuty(data);
/// return _heartbeat(data);
/// ```
///
/// `dutylogid` is written in exactly one place — a successful
/// `POST /miler/duty/start` — and it is zeroed on going off duty. So there are
/// two ordinary ways to end up on duty with a zero, and both are permanent:
///
/// • **The server already has the rider on duty.** `duty/start` answers 400,
/// which [_startDuty] correctly treats as a reconciliation rather than a
/// failure and delegates to [_currentDuty] — which read `dutylogid` out of
/// the response and wrote it to `logid` and `logId` **and not to
/// `dutylogid`**. The gate stayed shut. Next tick: `duty/start` again, 400
/// again, reconcile again, still shut. Forever, and silently, because
/// nothing in that loop is an error. It happens after a reinstall, after
/// cleared storage, after an `endDuty` that never landed, and on any fresh
/// login for a rider the backend still has on shift.
///
/// • **The response spells the id differently.** [_startDuty] read one key on
/// one level. Anything else — `logid`, `id`, a top-level field rather than
/// one under `data` — parsed to zero and was *stored* as zero, which puts a
/// rider into the same permanent loop on his very first shift. This codebase
/// has been bitten by exactly this before; see `AuthProvider._tokenIn`,
/// which looks in nine places for the bearer for precisely this reason.
///
/// ── The design error underneath both ──
///
/// `POST /miler/logs` does not take a duty log id. Its contract is
/// `{logdate, latitude, longitude, battery, is_charging, connection,
/// location_service, accuracy, is_background}` — nothing about duty. Gating
/// telemetry on an identifier the endpoint does not want, that the backend is
/// not obliged to return, is what turned one unmapped field into total
/// silence on both writes.
///
/// So the gate is now *"has duty been established"* — a fact — rather than
/// *"do we happen to hold a non-zero id"*, which is an artefact. The id is
/// still stored when it arrives, because [_dutyEnvelope] and the legacy duty
/// readers want it; it simply no longer decides whether a rider reports.
/// ─────────────────────────────────────────────────────────────────────────
/// Set once duty is known to be on, and cleared when it goes off.
///
/// Deliberately separate from `dutylogid`: this answers "should the heartbeat
/// run", which is a question about duty, and the id answers "which duty log
/// row", which is a question about the backend's bookkeeping. Conflating them
/// is the bug above.
const String _kDutyEstablished = 'duty_established';
/// Every spelling and nesting the duty endpoints have used for the log id.
///
/// Ordered by how the current contract answers, then by what deployments have
/// been seen to send. A miss is no longer fatal — see [_dutyEstablished] — but
/// finding the id keeps the legacy `logid` readers correct.
const List<String> _dutyLogIdKeys = [
'dutylogid',
'dutyLogId',
'duty_log_id',
'logid',
'logId',
'log_id',
'id',
];
/// The duty log id anywhere in [res], or `0`.
///
/// Looks in the envelope's `data` and in the undecorated body, because a
/// handler that returns `{success, dutylogid}` and one that returns
/// `{success, data:{dutylogid}}` are both shapes this contract has worn.
int _dutyLogIdIn(ApiResult res) {
final sources = <Map>[res.map, if (res.raw is Map) res.raw as Map];
for (final source in sources) {
for (final key in _dutyLogIdKeys) {
final v = source[key];
if (v == null) continue;
final n = int.tryParse(v.toString().trim());
if (n != null && n > 0) return n;
}
}
return 0;
}
/// Records that the rider is on duty, and the id if the backend sent one.
///
/// The flag is set whether or not there is an id, which is the whole fix: a
/// rider whose duty the server has confirmed reports, even when the response
/// did not name the row.
Future<void> _rememberDuty(int dutyLogId) async {
final prefs = await SharedPreferences.getInstance();
if (dutyLogId > 0) {
await prefs.setInt('logid', dutyLogId);
await prefs.setInt('logId', dutyLogId);
await prefs.setInt('dutylogid', dutyLogId);
}
await prefs.setBool(_kDutyEstablished, true);
await prefs.setInt('onduty', 1);
}
/// Forgets duty, on the way off shift.
Future<void> _forgetDuty() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('dutylogid', 0);
await prefs.setBool(_kDutyEstablished, false);
}
/// Whether duty has been established, so the heartbeat should run.
Future<bool> _dutyEstablished() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool(_kDutyEstablished) == true) return true;
// An install that predates the flag: a stored id means duty was started by
// an earlier build, and that rider must not have to go off and on again to
// start reporting.
return (prefs.getInt('dutylogid') ?? 0) > 0;
}
/// Legacy `{status, details}` for the duty-state readers. /// Legacy `{status, details}` for the duty-state readers.
Map<String, dynamic> _dutyEnvelope(Map data) { Map<String, dynamic> _dutyEnvelope(Map data) {
final bool on = data['onduty'] == true; final bool on = data['onduty'] == true;
@@ -54,11 +180,11 @@ Future<Map<String, dynamic>?> _startDuty(Map data) async {
lon: _d(data['longitude']), lon: _d(data['longitude']),
); );
if (res.ok) { if (res.ok) {
final id = int.tryParse('${res.map['dutylogid'] ?? 0}') ?? 0; // Looked at one key on one level and stored whatever it found, including
final prefs = await SharedPreferences.getInstance(); // a zero — which shut the heartbeat's gate permanently on a response that
await prefs.setInt('logid', id); // had merely spelled the id differently. See [_dutyLogIdIn].
await prefs.setInt('logId', id); final id = _dutyLogIdIn(res);
await prefs.setInt('dutylogid', id); await _rememberDuty(id);
return { return {
'status': true, 'status': true,
'code': 200, 'code': 200,
@@ -76,12 +202,19 @@ Future<Map<String, dynamic>?> _currentDuty() async {
if (!res.ok) return {'status': false, 'code': res.status}; if (!res.ok) return {'status': false, 'code': res.status};
final on = res.map['onduty'] == true; final on = res.map['onduty'] == true;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('onduty', on ? 1 : 0);
if (on) { if (on) {
final id = int.tryParse('${res.map['dutylogid'] ?? 0}') ?? 0; // ── The line that was missing ──
await prefs.setInt('logid', id); //
await prefs.setInt('logId', id); // This wrote the id to `logid` and `logId` and stopped, leaving
// `dutylogid` — the only key the heartbeat's gate reads — at zero. So the
// reconciliation that exists to recover from "the server already has you
// on duty" recovered the duty state and not the ability to report it, and
// the next tick came straight back here. See the note at the top.
await _rememberDuty(_dutyLogIdIn(res));
} else {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('onduty', 0);
await _forgetDuty();
} }
return _dutyEnvelope(res.map); return _dutyEnvelope(res.map);
} }
@@ -108,11 +241,28 @@ Future<Map<String, dynamic>?> _heartbeat(Map data) async {
heading: _d(data['heading']), heading: _d(data['heading']),
); );
// Deliberately not awaited into the result: a telemetry failure must never // ── Awaited, but its outcome is still discarded ──
// look like a location failure, because the caller treats the latter as duty //
// state going wrong. // This was `unawaited(...)`, for a good reason that turned out to be the
unawaited( // wrong mechanism: a telemetry failure must never read as a location failure,
MilerApi.postLog( // because the caller treats the latter as duty state going wrong. `unawaited`
// achieves that by never looking at the result — and also by not waiting for
// the request to finish.
//
// On Android this heartbeat runs inside `flutter_foreground_task`'s **own
// Flutter engine**, spun up per tick. When the callback returns, that engine
// can be suspended before an in-flight future completes. So the awaited
// `PUT /miler/location` above landed on every tick and this `POST /miler/logs`
// was killed mid-flight — which is exactly what the backend saw: a rider with
// a live position in Redis and no new telemetry row behind it, while the app
// logged a successful heartbeat.
//
// Awaiting it costs one round trip on a call that already had to happen and
// guarantees the request outlives the tick. The isolation the `unawaited` was
// protecting is kept by the `try`: the result is logged and thrown away, and
// nothing below branches on it.
try {
final logRes = await MilerApi.postLog(
latitude: lat, latitude: lat,
longitude: lon, longitude: lon,
speed: _d(data['speed']), speed: _d(data['speed']),
@@ -137,8 +287,18 @@ Future<Map<String, dynamic>?> _heartbeat(Map data) async {
? null ? null
: _s(data['location_service']), : _s(data['location_service']),
isBackground: data['is_background'] == true, isBackground: data['is_background'] == true,
), );
); // The one line that says whether telemetry reached the console. It was
// silent on success *and* on failure, which is why a dropped post took
// several rounds of backend archaeology to find.
debugPrint(
'[RIDERLOG][LOGS] POST /miler/logs -> ${logRes.status} '
'ok=${logRes.ok} battery=${_s(data['battery'])} '
'connection=${_s(data['connection'])}',
);
} catch (e) {
debugPrint('[RIDERLOG][LOGS] POST /miler/logs failed: $e');
}
return res.ok return res.ok
? ApiConfig.okEnvelope() ? ApiConfig.okEnvelope()
@@ -158,13 +318,28 @@ bool _isOffDutyIntent(Map data) {
class CreateRiderLogProvider { class CreateRiderLogProvider {
/// The first call of a session starts duty; every later one is a heartbeat. /// The first call of a session starts duty; every later one is a heartbeat.
///
/// ── The start is no longer a tick the rider loses ──
///
/// This returned [_startDuty]'s envelope and stopped, so the tick that opened
/// the shift carried no telemetry and no position — the console had nothing
/// until the next one, thirty seconds later. Worse, when `duty/start`
/// reconciled a rider the server already had on duty, the old gate never
/// opened at all and *every* tick ended here. See the note at the top of this
/// file.
///
/// Duty is established first, then the same payload beats immediately. The
/// heartbeat is best-effort and its result is not merged into the duty
/// envelope, because the caller reads that envelope for duty state and a
/// telemetry failure is not a duty failure.
Future<Map<String, dynamic>?> createRiderLog( Future<Map<String, dynamic>?> createRiderLog(
Map<String, dynamic> data, Map<String, dynamic> data,
) async { ) async {
final prefs = await SharedPreferences.getInstance(); if (await _dutyEstablished()) return _heartbeat(data);
final int existing = prefs.getInt('dutylogid') ?? 0;
if (existing <= 0) return _startDuty(data); final started = await _startDuty(data);
return _heartbeat(data); if (await _dutyEstablished()) await _heartbeat(data);
return started;
} }
} }
@@ -173,19 +348,25 @@ class UpdateRiderLogProvider {
Map<String, dynamic> data, Map<String, dynamic> data,
) async { ) async {
if (_isOffDutyIntent(data)) { if (_isOffDutyIntent(data)) {
final prefs = await SharedPreferences.getInstance(); await _forgetDuty();
await prefs.setInt('dutylogid', 0);
final res = await MilerApi.endDuty(); final res = await MilerApi.endDuty();
if (res.ok) await prefs.setInt('onduty', 0); if (res.ok) {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('onduty', 0);
}
return res.ok return res.ok
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true}) ? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
: {'status': false, 'code': res.status}; : {'status': false, 'code': res.status};
} }
final onduty = data['onduty']; final onduty = data['onduty'];
if (onduty == 1 || _s(onduty) == '1') { if ((onduty == 1 || _s(onduty) == '1') && !await _dutyEstablished()) {
final prefs = await SharedPreferences.getInstance(); // Same shape as [CreateRiderLogProvider.createRiderLog]: establish duty,
if ((prefs.getInt('dutylogid') ?? 0) <= 0) return _startDuty(data); // then beat on the same tick rather than returning and leaving the
// console with nothing for another interval.
final started = await _startDuty(data);
if (await _dutyEstablished()) await _heartbeat(data);
return started;
} }
return _heartbeat(data); return _heartbeat(data);
} }

View File

@@ -3,6 +3,7 @@ import 'package:http/http.dart' as http;
import 'dart:convert'; import 'dart:convert';
import 'package:miler/Models/login/login.dart'; import 'package:miler/Models/login/login.dart';
import 'package:miler/data/api_config.dart'; import 'package:miler/data/api_config.dart';
import 'package:miler/data/heartbeat.dart';
import 'package:miler/data/mock_backend.dart'; import 'package:miler/data/mock_backend.dart';
import 'package:miler/data/miler_api.dart'; import 'package:miler/data/miler_api.dart';
import 'package:miler/data/service_profile.dart'; import 'package:miler/data/service_profile.dart';
@@ -93,7 +94,7 @@ class AuthProvider {
int? pin, int? pin,
String? pinRaw, String? pinRaw,
}) async { }) async {
// The legacy `jupiter.doormile.app/.../rider/login` path that used to sit // The legacy `rider/login` path on the retired backend that used to sit
// behind a flag here is gone with the rest of the old backend. // behind a flag here is gone with the rest of the old backend.
return _loginNew( return _loginNew(
contactNo: contactNo, contactNo: contactNo,
@@ -303,6 +304,15 @@ class AuthProvider {
'logid': 0, 'logid': 0,
'partnerid': 0, 'partnerid': 0,
'configid': 0, 'configid': 0,
// ── Zero is not a safe default for this one ──
//
// Every other field here is an *identifier*, and an unknown identifier
// is honestly zero. `logseconds` is a **cadence**, and zero read as a
// cadence means "never beat" — which is exactly how both call sites in
// [RiderLogController] took it, so the rider-log loop never started on
// a v1 deployment and the console lost battery, connection, GPS and
// the rider's live position with it. See [kDefaultLogSeconds].
'logseconds': kDefaultLogSeconds,
// ── Which line of work this rider is on ── // ── Which line of work this rider is on ──
// //
// The server's answer first, the build's declared tenant only when // The server's answer first, the build's declared tenant only when
@@ -432,24 +442,6 @@ class AuthProvider {
} }
} }
// 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 // Convenience: parsed response as Login model
Future<Login> loginParsed({ Future<Login> loginParsed({
required String contactNo, required String contactNo,
@@ -498,7 +490,14 @@ class AuthProvider {
await prefs.setInt('partnerid', details['partnerid'] ?? 0); await prefs.setInt('partnerid', details['partnerid'] ?? 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('configid', details['configid'] ?? 0);
await prefs.setInt('logseconds', details['logseconds'] ?? 0); // Not `?? 0`. This is read back as the heartbeat's interval, and a zero
// there stops the loop starting at all — see [resolveLogSeconds]. A
// legacy backend that sends a real cadence still wins; anything else
// lands on the 30s floor rather than on silence.
await prefs.setInt(
'logseconds',
resolveLogSeconds(details['logseconds']),
);
await prefs.setInt('locationid', details['locationid'] ?? 0); await prefs.setInt('locationid', details['locationid'] ?? 0);
await prefs.setInt('tenantid', details['tenantid'] ?? 0); await prefs.setInt('tenantid', details['tenantid'] ?? 0);

View File

@@ -15,7 +15,7 @@ import 'package:miler/data/accepted_store.dart';
/// ── What changed ── /// ── What changed ──
/// ///
/// This file used to carry two complete implementations of every call: a /// This file used to carry two complete implementations of every call: a
/// legacy one that spoke to `queue.workolik.com` through a hand-built /// legacy one that spoke to the retired backend through a hand-built
/// `IOClient` — one that disabled certificate checking, pinned a hardcoded IP /// `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 /// 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 /// v1 one behind `if (ApiConfig.useNewApi)`. The legacy backend is gone, and

View File

@@ -2,12 +2,9 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:miler/Models/summary/pickupstats.dart'; import 'package:miler/Models/summary/pickupstats.dart';
import 'package:miler/data/api_config.dart'; import 'package:miler/data/api_config.dart';
import 'package:miler/views/helpers/constants/apiconstants.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
class SummaryProvider { class SummaryProvider {
final String baseUrl = ApiConstants.summaryApiLive;
Future<PickupStats?> fetchSummaryStats(int userId) => _fetchSummaryStatsNew(); Future<PickupStats?> fetchSummaryStats(int userId) => _fetchSummaryStatsNew();
/// `GET /miler/earnings?period=daily|weekly|monthly` /// `GET /miler/earnings?period=daily|weekly|monthly`

View File

@@ -17,7 +17,6 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:miler/providers/pickup/pickup_provider.dart'; import 'package:miler/providers/pickup/pickup_provider.dart';
import 'package:miler/providers/pickuplog/pickuplog_provider.dart'; import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
import 'package:miler/views/Dashboard/orders/orderstaus_button.dart'; import 'package:miler/views/Dashboard/orders/orderstaus_button.dart';
import 'package:miler/views/helpers/constants/apiconstants.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/constants/miler_surface.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart';
@@ -59,6 +58,7 @@ import 'package:miler/views/Dashboard/pickups/pickups.dart' as pickups;
import 'package:miler/background/live_tracking_service.dart'; import 'package:miler/background/live_tracking_service.dart';
import 'package:miler/data/accepted_store.dart'; import 'package:miler/data/accepted_store.dart';
import 'package:miler/data/api_config.dart'; import 'package:miler/data/api_config.dart';
import 'package:miler/data/heartbeat.dart';
import 'package:miler/data/consignment_state.dart'; import 'package:miler/data/consignment_state.dart';
import 'package:miler/views/Dashboard/pickups/collect_payment.dart'; import 'package:miler/views/Dashboard/pickups/collect_payment.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart';
@@ -1699,11 +1699,13 @@ class _HomepageState extends State<Homepage>
Future<Duration> _getLogInterval() async { Future<Duration> _getLogInterval() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final secs = prefs.getInt('logseconds'); // Already correct — routed through the shared resolver so the 30 lives in
final int interval = (secs != null && secs > 0) ? secs : 30; // one place. See [kDefaultLogSeconds] for what a zero here used to cost
// the rider-log loop, which read the same pref and did not guard it.
final int interval = resolveLogSeconds(prefs.getInt('logseconds'));
return Duration(seconds: interval); return Duration(seconds: interval);
} catch (_) { } catch (_) {
return const Duration(seconds: 30); return const Duration(seconds: kDefaultLogSeconds);
} }
} }

View File

@@ -932,7 +932,15 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
final top = media.padding.top + 72; final top = media.padding.top + 72;
await _camera.fit( await _camera.fit(
<LatLng>[_routeStart, _routeEnd], <LatLng>[_routeStart, _routeEnd],
padding: EdgeInsets.fromLTRB(48, top, 48, viewport * _sheetWorking + 24), // The sheet is lifted by the gesture bar, so the space it covers is
// taller than the bare fraction — see [_sheetLift]. Framing against the
// old figure would tuck the route's near end under the sheet.
padding: EdgeInsets.fromLTRB(
48,
top,
48,
viewport * (_sheetWorking + _sheetLift(media)) + 24,
),
); );
} }
@@ -1102,18 +1110,49 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
/// Three stops and no more: a glance, the working height, and the detail /// Three stops and no more: a glance, the working height, and the detail
/// view. A drag with many arbitrary rest points feels loose and gives the /// view. A drag with many arbitrary rest points feels loose and gives the
/// rider nothing to aim at. /// rider nothing to aim at.
/// The system's own bar at the foot of the screen, as a fraction of the
/// viewport — so the three snap fractions below can be lifted clear of it.
///
/// The snaps are fractions and the bar is an absolute height, so the two only
/// meet through the viewport. Clamped because a fraction derived from a
/// measurement should never be able to swallow the sheet on a device that
/// reports something strange.
double _sheetLift(MediaQueryData media) {
final double height = media.size.height;
if (height <= 0) return 0;
final double bar = math.max(
media.viewInsets.bottom,
media.viewPadding.bottom,
);
return (bar / height).clamp(0.0, 0.15);
}
static const double _sheetGlance = 0.35; static const double _sheetGlance = 0.35;
static const double _sheetWorking = 0.45; static const double _sheetWorking = 0.45;
static const double _sheetDetail = 0.75; static const double _sheetDetail = 0.75;
Widget _buildDraggableBottomSheet() { Widget _buildDraggableBottomSheet() {
// ── Every stop is lifted by the height of the gesture bar ──
//
// Padding the content alone would have kept the sheet the same height and
// simply moved everything in it up, costing the rider a bar's worth of the
// stop he is reading. Growing the sheet by the same amount puts the glass
// *behind* the bar and leaves the visible content area exactly the size it
// was — which is what "the sheet is touching the navigation" actually asks
// for. The two work together: this makes room, the list's bottom padding
// keeps the CTA out of it.
final double lift = _sheetLift(MediaQuery.of(context));
final double glance = (_sheetGlance + lift).clamp(0.0, 1.0);
final double working = (_sheetWorking + lift).clamp(0.0, 1.0);
final double detail = (_sheetDetail + lift).clamp(0.0, 1.0);
return DraggableScrollableSheet( return DraggableScrollableSheet(
initialChildSize: _sheetWorking, initialChildSize: working,
minChildSize: _sheetGlance, minChildSize: glance,
maxChildSize: _sheetDetail, maxChildSize: detail,
// Snap to the three, so the sheet always settles somewhere meant. // Snap to the three, so the sheet always settles somewhere meant.
snap: true, snap: true,
snapSizes: const [_sheetGlance, _sheetWorking, _sheetDetail], snapSizes: [glance, working, detail],
// ── Frosted, not opaque ── // ── Frosted, not opaque ──
// //
// A solid white slab over a map takes away 45% of the one thing this // A solid white slab over a map takes away 45% of the one thing this
@@ -1157,7 +1196,32 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
// has, and it is why that sheet reads at arm's length. // has, and it is why that sheet reads at arm's length.
child: ListView( child: ListView(
controller: scrollController, controller: scrollController,
padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 8.h), // ── The gesture bar sat on top of the CTA ──
//
// Eight points of bottom padding, on a sheet anchored to
// the bottom of the screen: the last thing in this list is
// [_buildPrimaryAction] — the slide control that closes the
// stop — and on a gesture-navigation phone the system's own
// bar was drawn across it. The rider's swipe either missed
// the control or went to the OS.
//
// The glass still bleeds to the screen edge, which is what
// it is for; what has to clear the bar is the *content*.
// Same rule the sheet kit applies to every modal sheet —
// `max(keyboard, gesture bar)`, never the sum, because the
// keyboard replaces the bar rather than stacking with it.
// This sheet is hand-built rather than presented through
// [MilerSheetScaffold], so it never inherited that rule.
padding: EdgeInsets.fromLTRB(
20.w,
16.h,
20.w,
8.h +
math.max(
MediaQuery.viewInsetsOf(context).bottom,
MediaQuery.viewPaddingOf(context).bottom,
),
),
children: [ children: [
_LiveEtaHeader( _LiveEtaHeader(
meters: _liveMetersToStop, meters: _liveMetersToStop,
@@ -1635,13 +1699,11 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
if (_isNavigating || !mounted) return; if (_isNavigating || !mounted) return;
final outcome = await DeliveryProofPage.askOutcome(context); final outcome = await DeliveryProofPage.askOutcome(context);
if (!mounted || outcome == null) return; if (!mounted || outcome == null) return;
await _closeDelivery( await _closeDelivery(switch (outcome) {
switch (outcome) { ProofOutcome.delivered => DeliveryOutcome.delivered,
ProofOutcome.delivered => DeliveryOutcome.delivered, ProofOutcome.skipped => DeliveryOutcome.skipped,
ProofOutcome.skipped => DeliveryOutcome.skipped, ProofOutcome.cancelled => DeliveryOutcome.cancelled,
ProofOutcome.cancelled => DeliveryOutcome.cancelled, });
},
);
} }
/// The end of the stop, whichever of the three it is. /// The end of the stop, whichever of the three it is.

View File

@@ -1,90 +0,0 @@
class ApiConstants {
static String mainDev = "dev";
static String mainRoute = "live";
// ── There is no maps key any more ──
//
// A `googleMapsApiKey` used to live here, feeding `flutter_polyline_points`
// and both native projects. It was revoked, so every tile request and every
// Directions call came back `REQUEST_DENIED` and the app looked broken rather
// than unconfigured.
//
// The maps no longer need one: tiles come from OpenStreetMap and routes from
// OSRM, both keyless. See `lib/views/helpers/widgets/miler_map.dart` and
// `lib/helpers/miler_router.dart` — including the note on what those free
// services are and are not suitable for.
//Collection Queue - v2
static String pickupQueueDev =
"https://jupiter.doormile.app/$mainDev/api/v2/pickups/getpickupqueues";
static String pickupQueueLive =
"https://jupiter.doormile.app/$mainRoute/api/v2/pickups/getpickupqueues";
//Current Collection - v1
static String currentPickupDev =
"https://jupiter.doormile.app/$mainDev/api/v1/pickups/getpickups";
static String currentPickupLive =
"https://jupiter.doormile.app/$mainRoute/api/v1/pickups/getpickups";
//Current Collection V3 - v3 (date-bounded)
static String currentPickupV3Dev =
"https://jupiter.doormile.app/$mainDev/api/v3/pickups/getpickups";
static String currentPickupV3Live =
"https://jupiter.doormile.app/$mainRoute/api/v3/pickups/getpickups";
//Update Collection - v1
static String updatePickupDev =
"https://queue.workolik.com/live/api/v1/pickups/updatepickup";
static String updatePickupLive =
"https://queue.workolik.com/live/api/v1/pickups/updatepickup";
//Get Rider Log - v1
static String getRiderLogDev =
"https://jupiter.doormile.app/$mainDev/api/v1/partners/getriderlog";
static String getRiderLogLive =
"https://jupiter.doormile.app/$mainRoute/api/v1/partners/getriderlog";
//Create Rider Log - v2
static String createRiderLogDev =
"https://queue.workolik.com/live/api/v2/partners/createriderlog";
static String createRiderLogLive =
"https://queue.workolik.com/live/api/v2/partners/createriderlog";
//Update Rider Log - v1
static String updateRiderLogDev =
"https://jupiter.doormile.app/$mainDev/api/v1/partners/updateriderlog";
static String updateRiderLogLive =
"https://jupiter.doormile.app/$mainRoute/api/v1/partners/updateriderlog";
//Get Rider Count - v1
static String getRiderCountDev =
"https://jupiter.doormile.app/$mainDev/api/v1/partners/getridercount";
static String getRiderCountLive =
"https://jupiter.doormile.app/$mainRoute/api/v1/partners/getridercount";
//Create Break Rider Log - v2
static String createBreakRiderLogDev =
"https://queue.workolik.com/live/api/v2/partners/createbreaklog";
static String createBreakRiderLogLive =
"https://queue.workolik.com/live/api/v2/partners/createbreaklog";
//Update Break Rider Log - v2
static String updateBreakRiderLogDev =
"https://queue.workolik.com/live/api/v2/partners/updatebreaklog";
static String updateBreakRiderLogLive =
"https://queue.workolik.com/live/api/v2/partners/updatebreaklog";
//Create Collection Log - v2
static String createPickupLogDev =
"https://queue.workolik.com/live/api/v2/pickups/createpickuplog";
static String createPickupLogLive =
"https://queue.workolik.com/live/api/v2/pickups/createpickuplog";
//Summary API - v2
static String summaryApiLive =
'https://jupiter.doormile.app/$mainRoute/api/v2/partners';
//Summary Rider Weekly KMs - v1
static String summaryriderkmLive =
'https://jupiter.doormile.app/$mainRoute/api/v1/partners';
}

View File

@@ -3,7 +3,8 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_tts/flutter_tts.dart'; import 'package:flutter_tts/flutter_tts.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:http/http.dart' as http; import 'package:miler/data/api_config.dart';
import 'package:miler/data/miler_api.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:audioplayers/audioplayers.dart'; import 'package:audioplayers/audioplayers.dart';
import 'dart:math' as math; import 'dart:math' as math;
@@ -20,7 +21,6 @@ class BackgroundDeliveryLog {
static final CreateDeliveryLogProvider _logProvider = static final CreateDeliveryLogProvider _logProvider =
CreateDeliveryLogProvider(); CreateDeliveryLogProvider();
static final http.Client _httpClient = http.Client();
static final FlutterTts _tts = FlutterTts(); static final FlutterTts _tts = FlutterTts();
static final AudioPlayer _proximityPlayer = AudioPlayer(); static final AudioPlayer _proximityPlayer = AudioPlayer();
@@ -60,38 +60,37 @@ class BackgroundDeliveryLog {
final now = DateTime.now(); final now = DateTime.now();
final today = '${now.year}-${_pad(now.month)}-${_pad(now.day)}'; final today = '${now.year}-${_pad(now.month)}-${_pad(now.day)}';
// Build API URL using v1 endpoint (getdeliveries) // ── The delivery twin of the pickup fetch, with the same two faults ──
final bool isLive = ApiConstants.mainRoute == 'live'; //
final baseUrl = isLive // It built a `deliveries/getdeliveries` URL by hand and fetched it with a
? ApiConstants.currentDeliveryLive // bare `_httpClient.get` — **no bearer token** — and put `userid` in the
: ApiConstants.currentDeliveryDev; // query string. The URL itself already resolved to a v1 route through the
// [DeliveryApi] facade, so the call could only ever be answered 401.
final uri = Uri.parse(baseUrl).replace( //
queryParameters: { // And the filter looked for `orderstatus == 'active'`, which is not a
'userid': userId.toString(), // value this app's vocabulary contains: [ApiConfig.legacyStatusFromNew]
'fromdate': today, // maps the v1 statuses to `assigned` / `accepted` / `arrived` / `picked`,
'todate': today, // never to `active`. So it returned nothing on every tick and fell
't': DateTime.now().millisecondsSinceEpoch.toString(), // through to the shift-end check, which is what the device log shows.
}, //
); // `GET /miler/bookings` through [MilerApi], asked with the session, about
// the rider the session names.
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Fetching deliveries: $uri'); final res = await MilerApi.bookings(date: today);
final deliveries = res.list;
// Fetch deliveries from API using v1 endpoint
final deliveries = await _fetchDeliveriesFromApi(uri);
debugPrint( debugPrint(
'[ACTIVE_DELIVERY_LOG][BG] API returned ${deliveries.length} items', '[ACTIVE_DELIVERY_LOG][BG] /miler/bookings -> ${res.status}, '
'${deliveries.length} items',
); );
// Filter for active orders only const inProgress = {'accepted', 'arrived', 'picked'};
final activeOrders = deliveries.whereType<Map<String, dynamic>>().where(( final activeOrders = deliveries.whereType<Map<String, dynamic>>().where((
order, order,
) { ) {
final status = (order['orderstatus']?.toString().toLowerCase() ?? '') final status = ApiConfig.legacyStatusFromNew(
.trim(); (order['status'] ?? order['orderstatus'])?.toString(),
final isActive = status == 'active'; );
return isActive; return inProgress.contains(status);
}).toList(); }).toList();
if (activeOrders.isEmpty) { if (activeOrders.isEmpty) {
@@ -202,11 +201,8 @@ class BackgroundDeliveryLog {
'logstatus': await _resolveLogStatus(orderId, coords), 'logstatus': await _resolveLogStatus(orderId, coords),
}; };
// Determine API endpoint // v1: `/miler/consignments/logs`, via the DeliveryApi facade.
final url = ApiConstants.mainRoute == 'live' final url = ApiConstants.createDeliveryLogLive;
? ApiConstants.createDeliveryLogLive
: ApiConstants.createDeliveryLogDev;
// Post the log // Post the log
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Posting log for $orderId'); debugPrint('[ACTIVE_DELIVERY_LOG][BG] Posting log for $orderId');
@@ -434,33 +430,6 @@ class BackgroundDeliveryLog {
} }
/// Fetch deliveries from API using v1 endpoint /// Fetch deliveries from API using v1 endpoint
static Future<List<dynamic>> _fetchDeliveriesFromApi(Uri uri) async {
try {
final res = await _httpClient
.get(uri)
.timeout(const Duration(seconds: 10));
if (res.statusCode >= 200 && res.statusCode < 300) {
final decoded = json.decode(res.body);
final data = decoded is Map<String, dynamic>
? (decoded['details'] ?? decoded['data'] ?? decoded)
: decoded;
if (data is List) {
return data;
}
if (data is Map && data['items'] is List) {
return data['items'] as List;
}
return [];
}
return [];
} catch (e) {
return [];
}
}
/// Helper to pad numbers with leading zero /// Helper to pad numbers with leading zero
static String _pad(int n) => n.toString().padLeft(2, '0'); static String _pad(int n) => n.toString().padLeft(2, '0');
@@ -540,10 +509,6 @@ class BackgroundDeliveryLog {
final List<String> remaining = []; final List<String> remaining = [];
bool anySuccess = false; bool anySuccess = false;
final url = ApiConstants.mainRoute == 'live'
? ApiConstants.createDeliveryLogLive
: ApiConstants.createDeliveryLogDev;
for (final itemStr in queue) { for (final itemStr in queue) {
try { try {
final Map<String, dynamic> item = jsonDecode(itemStr); final Map<String, dynamic> item = jsonDecode(itemStr);
@@ -554,6 +519,8 @@ class BackgroundDeliveryLog {
if (payload.isEmpty) continue; if (payload.isEmpty) continue;
// v1: `/miler/consignments/logs`, via the DeliveryApi facade.
final url = ApiConstants.createDeliveryLogLive;
debugPrint( debugPrint(
'[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Retrying for orderId: $orderId', '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Retrying for orderId: $orderId',
); );
@@ -770,14 +737,18 @@ class BackgroundDeliveryLog {
'[AUTO_SHIFT_END] 🚀 Initiating auto-break and offline sequence...', '[AUTO_SHIFT_END] 🚀 Initiating auto-break and offline sequence...',
); );
// 1. Get Required IDs // ── The four ids and the fix are no longer this function's business ──
//
// `userid`, `partnerid`, `shiftid`, `logid` and a position were assembled
// here to fill a legacy break-log body. On v1 the break and the duty end
// are both keyed on the **token**: `POST /miler/breaks/start` opens a
// break for whoever the session names, and `PUT /miler/duty/end` closes
// whichever duty log is open for them. Sending an id would not make it
// more correct; it is what let the old call be made unauthenticated.
//
// `userid` is still read, as the one guard worth keeping: no signed-in
// rider, nothing to end.
final int userid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; final int userid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0;
final int partnerid =
prefs.getInt('partnerid') ?? prefs.getInt('partnerId') ?? 0;
final int shiftid =
prefs.getInt('shiftid') ?? prefs.getInt('shiftId') ?? 0;
final int logid = prefs.getInt('logid') ?? prefs.getInt('logId') ?? 0;
if (userid == 0) { if (userid == 0) {
debugPrint( debugPrint(
'[AUTO_SHIFT_END] ❌ Missing userid, cannot create break log', '[AUTO_SHIFT_END] ❌ Missing userid, cannot create break log',
@@ -785,61 +756,28 @@ class BackgroundDeliveryLog {
return; return;
} }
// 2. Get Location
final Map<String, String>? coords = await _getCoordinatesWithFallback();
final String lat = coords?['lat'] ?? '0';
final String lng = coords?['lng'] ?? '0';
// 3. Prepare Break Log Payload
final now = DateTime.now(); final now = DateTime.now();
final int localBreakId = final int localBreakId =
(DateTime.now().millisecondsSinceEpoch % 900) + 100; // Random-ish ID (DateTime.now().millisecondsSinceEpoch % 900) + 100; // Random-ish ID
final String breakdate =
'${now.year}-${_pad(now.month)}-${_pad(now.day)} ${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}';
final String breakstart = final String breakstart =
'${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}'; '${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}';
final payload = <String, dynamic>{ // 4. Close the shift with a break, on v1.
"breakid": localBreakId, //
"logid": logid, // This hand-posted the retired backend's `partners/createbreaklog`
"breakdate": breakdate, // with a bare `_httpClient.post` and no bearer
"userid": userid, // token — an unauthenticated write keyed on a `userid` in the
"partnerid": partnerid, // body. `POST /miler/breaks/start` is the v1 endpoint, and
"shiftid": shiftid, // [MilerApi] attaches the session.
"breakstart": breakstart, debugPrint('[AUTO_SHIFT_END] Creating break log (v1)');
"breakend": "",
"breakhours": 0.0,
"latitude": lat,
"longitude": lng,
};
// 4. Call API to Create Break
final url = ApiConstants.mainRoute == 'live'
? ApiConstants.createBreakRiderLogLive
: ApiConstants.createBreakRiderLogDev;
debugPrint('[AUTO_SHIFT_END] Creating break log: $url');
// We use a separate provider instance or http call if needed,
// but _logProvider is for delivery logs. We need a generic post or use http directly.
// Since we don't have BreakRiderLogProvider here, we'll use http directly for simplicity and isolation.
try { try {
final response = await _httpClient final res = await MilerApi.startBreak('Shift_End');
.post(
Uri.parse(url),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(payload),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode >= 200 && response.statusCode < 300) { if (res.ok) {
debugPrint('[AUTO_SHIFT_END] ✅ Break log created successfully'); debugPrint('[AUTO_SHIFT_END] ✅ Break log created successfully');
// Parse response to get server break ID if needed, but mainly we just need to go offline final serverBreakId = res.map['breaklogid'] ?? res.map['breakid'];
final body = jsonDecode(response.body);
final det = (body['details'] is Map) ? body['details'] : body;
final serverBreakId = det['breakid'];
if (serverBreakId != null) { if (serverBreakId != null) {
await prefs.setInt( await prefs.setInt(
@@ -851,7 +789,7 @@ class BackgroundDeliveryLog {
await prefs.setInt('break_start_epoch', now.millisecondsSinceEpoch); await prefs.setInt('break_start_epoch', now.millisecondsSinceEpoch);
} else { } else {
debugPrint( debugPrint(
'[AUTO_SHIFT_END] ⚠️ Failed to create break log: ${response.statusCode}', '[AUTO_SHIFT_END] ⚠️ Failed to create break log: ${res.status}',
); );
} }
} catch (e) { } catch (e) {
@@ -863,27 +801,15 @@ class BackgroundDeliveryLog {
await prefs.setInt('onduty', 0); await prefs.setInt('onduty', 0);
await prefs.setBool('online', false); await prefs.setBool('online', false);
// 6. Update Rider Log (Set Duty = 0) // 6. End duty, on v1.
// We should also update the main rider log to say onduty=0 //
final updateUrl = ApiConstants.mainRoute == 'live' // Was an unauthenticated POST of `{userid, onduty: 0}` to the
? ApiConstants.updateRiderLogLive // legacy `updateriderlog`. `PUT /miler/duty/end` closes whichever
: ApiConstants.updateRiderLogDev; // duty log is open for the token's rider, which is the same fact
// without the id or the open door.
final updatePayload = {
"userid": userid,
"onduty": 0,
"latitude": lat,
"longitude": lng,
};
try { try {
await _httpClient await MilerApi.endDuty();
.post(
Uri.parse(updateUrl),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(updatePayload),
)
.timeout(const Duration(seconds: 5));
debugPrint('[AUTO_SHIFT_END] ✅ Rider status updated to Offline'); debugPrint('[AUTO_SHIFT_END] ✅ Rider status updated to Offline');
} catch (_) {} } catch (_) {}

View File

@@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/xpress/views/helpers/constants/apiconstants.dart';
import 'package:miler/xpress/providers/Riderlog/riderlog_provider.dart'; import 'package:miler/xpress/providers/Riderlog/riderlog_provider.dart';
import 'package:miler/xpress/background/backgroundservice.dart'; import 'package:miler/xpress/background/backgroundservice.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
@@ -354,12 +353,9 @@ class _BackgroundRiderLog {
payload['lastname'] = lastName.trim(); payload['lastname'] = lastName.trim();
} }
final base = ApiConstants.mainRoute == 'live'
? ApiConstants.createRiderLogLive
: ApiConstants.createRiderLogDev;
final provider = CreateRiderLogProvider(); final provider = CreateRiderLogProvider();
final resp = await provider.createRiderLog(base, payload); final resp = await provider.createRiderLog(payload);
if (resp == null || resp.isEmpty) return; if (resp == null || resp.isEmpty) return;
final det = (resp['details'] is Map<String, dynamic>) final det = (resp['details'] is Map<String, dynamic>)

View File

@@ -1,7 +1,6 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:miler/xpress/views/helpers/constants/apiconstants.dart';
import 'package:miler/xpress/Models/riders/riders_models.dart'; import 'package:miler/xpress/Models/riders/riders_models.dart';
import 'package:miler/xpress/providers/Riderlog/riderlog_provider.dart'; import 'package:miler/xpress/providers/Riderlog/riderlog_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -15,6 +14,7 @@ import 'package:miler/xpress/controllers/logcontroller.dart';
import 'package:miler/xpress/background/foreground_service.dart' as fg; import 'package:miler/xpress/background/foreground_service.dart' as fg;
import 'package:miler/xpress/utils/kalman_filter.dart'; import 'package:miler/xpress/utils/kalman_filter.dart';
import 'package:miler/xpress/utils/mqtt_service.dart'; import 'package:miler/xpress/utils/mqtt_service.dart';
import 'package:miler/data/heartbeat.dart';
import 'package:miler/xpress/views/helpers/constants/mqtt_constants.dart'; import 'package:miler/xpress/views/helpers/constants/mqtt_constants.dart';
import 'package:battery_plus/battery_plus.dart'; import 'package:battery_plus/battery_plus.dart';
@@ -40,11 +40,9 @@ class RiderLogController extends GetxController {
Future<void> fetchRiderLogs({required String userId}) async { Future<void> fetchRiderLogs({required String userId}) async {
isLoading.value = true; isLoading.value = true;
try { try {
final base = ApiConstants.mainRoute == 'live' // v1 answers duty for the *token's* rider — `userid` is neither sent
? ApiConstants.getRiderLogLive // nor needed. See [GetRiderLogProvider].
: ApiConstants.getRiderLogDev; final response = await _getProvider.getRiderLog();
final url = "$base?userid=$userId";
final response = await _getProvider.getRiderLog(url);
// Handle either {details: {...}} or {data: [...]} shapes // Handle either {details: {...}} or {data: [...]} shapes
if (response == null) { if (response == null) {
@@ -122,11 +120,7 @@ class RiderLogController extends GetxController {
} }
Future<void> fetchRiderCount({required String userId}) async { Future<void> fetchRiderCount({required String userId}) async {
final base = ApiConstants.mainRoute == 'live' final response = await _getProvider.getRiderCount();
? ApiConstants.getRiderCountLive
: ApiConstants.getRiderCountDev;
final url = "$base?userid=$userId";
final response = await _getProvider.getRiderCount(url);
final dynamic cnt = response != null ? response['count'] : null; final dynamic cnt = response != null ? response['count'] : null;
riderCount.value = cnt is num riderCount.value = cnt is num
? cnt.toInt() ? cnt.toInt()
@@ -134,46 +128,30 @@ class RiderLogController extends GetxController {
} }
Future<bool> createLogin(RiderLogin login) async { Future<bool> createLogin(RiderLogin login) async {
final base = ApiConstants.mainRoute == 'live'
? ApiConstants.createRiderLogLive
: ApiConstants.createRiderLogDev;
final ok = await _createProvider.createRiderLog( final ok = await _createProvider.createRiderLog(
base,
_CreateRiderLogRequestCompat.fromRiderLogin(login), _CreateRiderLogRequestCompat.fromRiderLogin(login),
); );
return ok != null && ok.isNotEmpty; return ok != null && ok.isNotEmpty;
} }
Future<bool> updateLog(RiderUpdate update) async { Future<bool> updateLog(RiderUpdate update) async {
final base = ApiConstants.mainRoute == 'live'
? ApiConstants.updateRiderLogLive
: ApiConstants.updateRiderLogDev;
final ok = await _updateProvider.updateRiderLog( final ok = await _updateProvider.updateRiderLog(
base,
_UpdateRiderLogRequestCompat.fromRiderUpdate(update), _UpdateRiderLogRequestCompat.fromRiderUpdate(update),
); );
return ok != null && ok.isNotEmpty; return ok != null && ok.isNotEmpty;
} }
Future<bool> createBreak(RiderBreak brk) async { Future<bool> createBreak(RiderBreak brk) async {
final base = ApiConstants.mainRoute == 'live'
? ApiConstants.createBreakRiderLogLive
: ApiConstants.createBreakRiderLogDev;
final ok = await _breakProvider.createBreakRiderLog( final ok = await _breakProvider.createBreakRiderLog(
base,
_BreakLogRequestCompat.fromRiderBreak(brk), _BreakLogRequestCompat.fromRiderBreak(brk),
); );
return ok != null && ok.isNotEmpty; return ok != null && ok.isNotEmpty;
} }
Future<bool> updateBreak(RiderBreak brk) async { Future<bool> updateBreak(RiderBreak brk) async {
final base = ApiConstants.mainRoute == 'live' // `PUT /miler/breaks/end` takes no body and closes whichever break is
? ApiConstants.updateBreakRiderLogLive // open, which the server knows and the app was only guessing at.
: ApiConstants.updateBreakRiderLogDev; final ok = await _breakProvider.updateBreakRiderLog();
final ok = await _breakProvider.updateBreakRiderLog(
base,
_BreakLogRequestCompat.fromRiderBreak(brk),
);
return ok != null && ok.isNotEmpty; return ok != null && ok.isNotEmpty;
} }
@@ -190,10 +168,6 @@ class RiderLogController extends GetxController {
required String latitude, required String latitude,
required String longitude, required String longitude,
}) async { }) async {
final url = ApiConstants.mainRoute == 'live'
? ApiConstants.updateBreakRiderLogLive
: ApiConstants.updateBreakRiderLogDev;
final payload = { final payload = {
"breakid": breakid, "breakid": breakid,
"logid": logid, "logid": logid,
@@ -209,11 +183,9 @@ class RiderLogController extends GetxController {
}; };
try { try {
// Debug: show exact URL and JSON body
debugPrint('[BREAK][UPDATE] URL: $url');
debugPrint('[BREAK][UPDATE] Body: $payload'); debugPrint('[BREAK][UPDATE] Body: $payload');
final response = await _breakProvider.updateBreakRiderLog(url, payload); final response = await _breakProvider.updateBreakRiderLog();
// Debug: show raw response // Debug: show raw response
debugPrint( debugPrint(
@@ -375,13 +347,9 @@ class RiderLogController extends GetxController {
payload.remove('lastname'); payload.remove('lastname');
} }
final base = ApiConstants.mainRoute == 'live'
? ApiConstants.createRiderLogLive
: ApiConstants.createRiderLogDev;
debugPrint('[RIDERLOG][CREATE LOGIN NOW] URL: $base');
debugPrint('[RIDERLOG][CREATE LOGIN NOW] Payload: $payload'); debugPrint('[RIDERLOG][CREATE LOGIN NOW] Payload: $payload');
final resp = await _createProvider.createRiderLog(base, payload); final resp = await _createProvider.createRiderLog(payload);
debugPrint( debugPrint(
'[RIDERLOG][CREATE LOGIN NOW] Response: ${resp == null ? 'null' : resp.toString()}', '[RIDERLOG][CREATE LOGIN NOW] Response: ${resp == null ? 'null' : resp.toString()}',
); );
@@ -391,7 +359,7 @@ class RiderLogController extends GetxController {
debugPrint( debugPrint(
'[RIDERLOG][CREATE LOGIN NOW] Failed, saving to offline queue', '[RIDERLOG][CREATE LOGIN NOW] Failed, saving to offline queue',
); );
await _saveToOfflineQueue(base, payload); await _saveToOfflineQueue('/miler/logs', payload);
return false; return false;
} }
@@ -500,11 +468,7 @@ class RiderLogController extends GetxController {
payload.remove('lastname'); payload.remove('lastname');
} }
final base = ApiConstants.mainRoute == 'live' await _saveToOfflineQueue('/miler/logs', payload);
? ApiConstants.createRiderLogLive
: ApiConstants.createRiderLogDev;
await _saveToOfflineQueue(base, payload);
} }
} catch (e) { } catch (e) {
debugPrint('[RIDERLOG] Error saving offline log: $e'); debugPrint('[RIDERLOG] Error saving offline log: $e');
@@ -532,10 +496,15 @@ class RiderLogController extends GetxController {
await prefs.setInt('onduty', on ? 1 : 0); await prefs.setInt('onduty', on ? 1 : 0);
if (on) { if (on) {
await createLoginNowV2(); await createLoginNowV2();
final int interval = prefs.getInt('logseconds') ?? 0; // ── Going on duty always starts the heartbeat ──
if (interval > 0) { //
startAutoCreateLoginLoop(seconds: interval); // Gated on `interval > 0`, this read a stored zero as a decision not
} // to beat. The v1 login has no `logseconds` to store, so the gate was
// shut on every rider: duty went on, one log was posted by the call
// above, and nothing followed it. Mirrors the logistics controller —
// see [resolveLogSeconds].
final int interval = resolveLogSeconds(prefs.getInt('logseconds'));
startAutoCreateLoginLoop(seconds: interval);
// Ensure foreground logging notification is started when going on-duty // Ensure foreground logging notification is started when going on-duty
try { try {
@@ -604,16 +573,43 @@ class RiderLogController extends GetxController {
} }
} }
/// Whether the periodic heartbeat is currently running on the stream path.
///
/// ── Why a test needs to be able to see this ──
///
/// The `logseconds` bug was invisible from outside the class. Duty went on,
/// the API accepted it, one log was posted by the immediate call — and the
/// loop that was supposed to follow it simply never started. Nothing threw,
/// nothing logged an error, and the only symptom was a console panel that had
/// been blank for so long it read as a backend problem.
///
/// Android normally runs the heartbeat as a foreground service instead, and
/// leaves this null; the test host is never Android, so the stream path is
/// the one under test. See [startAutoCreateLoginLoop].
@visibleForTesting
bool get autoLoopRunning => _autoLoginSubscription != null;
// Start periodic createRiderLog calls based on seconds (or prefs 'logseconds') // Start periodic createRiderLog calls based on seconds (or prefs 'logseconds')
// ✅ CRITICAL: When there are active deliveries, use 30 seconds (same as delivery logs) // ✅ CRITICAL: When there are active deliveries, use 30 seconds (same as delivery logs)
// Otherwise, use the configured logseconds interval // Otherwise, use the configured logseconds interval
void startAutoCreateLoginLoop({int? seconds}) async { /// `Future<void>`, not `void`. It was fire-and-forget, which meant no caller
/// could wait for the loop to actually be up — and the one place that most
/// wanted to, [setOnDuty], had no way to tell whether the heartbeat it had
/// just asked for existed. Every existing call site ignores the future and is
/// unchanged by this.
Future<void> startAutoCreateLoginLoop({int? seconds}) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
// ✅ Check if there are active deliveries - if yes, use 30 seconds (same as delivery logs) // ✅ Check if there are active deliveries - if yes, use 30 seconds (same as delivery logs)
final bool hasActiveDeliveries = final bool hasActiveDeliveries =
prefs.getBool('has_live_deliveries') ?? false; prefs.getBool('has_live_deliveries') ?? false;
final int baseInterval = seconds ?? (prefs.getInt('logseconds') ?? 0); // The configured cadence, or the floor under it. `?? 0` here was the
// second half of the same bug as the one in [setOnDuty]: a caller passing
// no `seconds` fell through to a stored zero and returned at the
// `interval <= 0` guard below. See [resolveLogSeconds].
final int baseInterval = resolveLogSeconds(
seconds ?? prefs.getInt('logseconds'),
);
// When there are active deliveries, post rider logs every 30 seconds (matching delivery logs) // When there are active deliveries, post rider logs every 30 seconds (matching delivery logs)
// Otherwise, use the configured interval // Otherwise, use the configured interval
@@ -625,12 +621,18 @@ class RiderLogController extends GetxController {
); );
} }
await stopAutoCreateLoginLoop(); // Cancel existing subscription // Stop before start, which is what keeps this method safe to call from
// every place that does. Whichever call arrives last wins, and there is
// never a second loop: the subscription is cancelled and the foreground
// service stopped before either is created again.
await stopAutoCreateLoginLoop();
// Attempt to flush offline logs on loop start // Attempt to flush offline logs on loop start
flushOfflineLogs(); flushOfflineLogs();
if (interval <= 0) return; // No `interval <= 0` guard. [resolveLogSeconds] cannot return one, and the
// guard that used to be here is what turned a missing `logseconds` into a
// rider who never reported.
final int onduty = prefs.getInt('onduty') ?? 0; final int onduty = prefs.getInt('onduty') ?? 0;
if (onduty != 1) { if (onduty != 1) {
debugPrint('[RIDERLOG][AUTO LOOP] Not starting - onduty=$onduty'); debugPrint('[RIDERLOG][AUTO LOOP] Not starting - onduty=$onduty');
@@ -921,10 +923,6 @@ class RiderLogController extends GetxController {
final breakdate = _formatDateTimeFull(now); // e.g. 2025-10-16 16:36:16 final breakdate = _formatDateTimeFull(now); // e.g. 2025-10-16 16:36:16
final breakstart = _formatTime(now); // e.g. 16:36:16 final breakstart = _formatTime(now); // e.g. 16:36:16
final url = ApiConstants.mainRoute == 'live'
? ApiConstants.createBreakRiderLogLive
: ApiConstants.createBreakRiderLogDev;
// Build payload with all required fields in the exact format the API expects // Build payload with all required fields in the exact format the API expects
final payload = <String, dynamic>{ final payload = <String, dynamic>{
"breakid": localBreakId, "breakid": localBreakId,
@@ -941,13 +939,12 @@ class RiderLogController extends GetxController {
}; };
// Debug logs for terminal visibility // Debug logs for terminal visibility
debugPrint('[BREAK][CREATE] URL: $url');
debugPrint('[BREAK][CREATE] Payload: $payload'); debugPrint('[BREAK][CREATE] Payload: $payload');
// Persist local break id immediately to ensure update can reference it // Persist local break id immediately to ensure update can reference it
await prefs.setInt('breakId', localBreakId); await prefs.setInt('breakId', localBreakId);
final resp = await _breakProvider.createBreakRiderLog(url, payload); final resp = await _breakProvider.createBreakRiderLog(payload);
debugPrint( debugPrint(
'[BREAK][CREATE] Response: ${resp == null ? 'null' : resp.toString()}', '[BREAK][CREATE] Response: ${resp == null ? 'null' : resp.toString()}',
@@ -1045,9 +1042,6 @@ class RiderLogController extends GetxController {
final breakhoursDouble = duration.inSeconds / 3600.0; final breakhoursDouble = duration.inSeconds / 3600.0;
// Debug logs for terminal visibility // Debug logs for terminal visibility
debugPrint(
'[BREAK][UPDATE] URL: ${ApiConstants.mainRoute == 'live' ? ApiConstants.updateBreakRiderLogLive : ApiConstants.updateBreakRiderLogDev}',
);
debugPrint( debugPrint(
'[BREAK][UPDATE] Fields: breakid=$breakid, logid=$logid, userid=$userid, partnerid=$partnerid, shiftid=$shiftid, breakend=$breakend, breakhours=$breakhoursDouble, lat=$latitude, lng=$longitude', '[BREAK][UPDATE] Fields: breakid=$breakid, logid=$logid, userid=$userid, partnerid=$partnerid, shiftid=$shiftid, breakend=$breakend, breakhours=$breakhoursDouble, lat=$latitude, lng=$longitude',
); );
@@ -1101,10 +1095,6 @@ class RiderLogController extends GetxController {
final iso = _formatDateTimeFull(now); final iso = _formatDateTimeFull(now);
final loginTime = _formatTime(now); final loginTime = _formatTime(now);
final url = ApiConstants.mainRoute == 'live'
? ApiConstants.createRiderLogLive
: ApiConstants.createRiderLogDev;
// ✅ Check if there are active deliveries to set status // ✅ Check if there are active deliveries to set status
final bool hasActiveDeliveries = final bool hasActiveDeliveries =
prefs.getBool('has_live_deliveries') ?? false; prefs.getBool('has_live_deliveries') ?? false;
@@ -1141,10 +1131,9 @@ class RiderLogController extends GetxController {
userfcmtoken: userfcmtoken ?? '', userfcmtoken: userfcmtoken ?? '',
).toJson(); ).toJson();
debugPrint('[RIDERLOG][CREATE LOGIN] URL: $url');
debugPrint('[RIDERLOG][CREATE LOGIN] Payload: $payload'); debugPrint('[RIDERLOG][CREATE LOGIN] Payload: $payload');
final resp = await _createProvider.createRiderLog(url, payload); final resp = await _createProvider.createRiderLog(payload);
debugPrint( debugPrint(
'[RIDERLOG][CREATE LOGIN] Response: ${resp == null ? 'null' : resp.toString()}', '[RIDERLOG][CREATE LOGIN] Response: ${resp == null ? 'null' : resp.toString()}',
); );
@@ -1205,7 +1194,7 @@ class RiderLogController extends GetxController {
// We can use _createProvider generic call // We can use _createProvider generic call
debugPrint('[RIDERLOG][OFFLINE] Retrying: $payload'); debugPrint('[RIDERLOG][OFFLINE] Retrying: $payload');
final resp = await _createProvider.createRiderLog(url, payload); final resp = await _createProvider.createRiderLog(payload);
if (resp != null && resp.isNotEmpty) { if (resp != null && resp.isNotEmpty) {
debugPrint('[RIDERLOG][OFFLINE] Success!'); debugPrint('[RIDERLOG][OFFLINE] Success!');

View File

@@ -107,7 +107,7 @@ class DeliveryApi {
/// **ABSENT** — the generic status write the ported flow uses for every /// **ABSENT** — the generic status write the ported flow uses for every
/// transition (accepted → active → arrived → delivered → skipped). /// transition (accepted → active → arrived → delivered → skipped).
/// ///
/// Was `queue.workolik.com/live/api/v1/deliveries/updatedelivery`. There is no /// Was `deliveries/updatedelivery` on the retired backend. There is no
/// single status-write route on Doormile; the transitions above are separate /// single status-write route on Doormile; the transitions above are separate
/// endpoints. Call sites that still post a bare status change land here and /// endpoints. Call sites that still post a bare status change land here and
/// will 404 until either they are split or the route is built. /// will 404 until either they are split or the route is built.
@@ -117,7 +117,7 @@ class DeliveryApi {
/// **LIVE** — periodic location/telemetry log. /// **LIVE** — periodic location/telemetry log.
/// ///
/// Was `queue.workolik.com/live/api/v2/partners/createriderlog`. /// Was `partners/createriderlog` on the retired backend.
static String get createRiderLog => url('/miler/logs'); static String get createRiderLog => url('/miler/logs');
/// **LIVE** — read them back. /// **LIVE** — read them back.
@@ -142,7 +142,7 @@ class DeliveryApi {
/// **ABSENT** — per-delivery event log. /// **ABSENT** — per-delivery event log.
/// ///
/// Was `queue.workolik.com/live/api/v2/deliveries/createdeliverylog`. The /// Was `deliveries/createdeliverylog` on the retired backend. The
/// closest real thing is consignment logs, which key on a consignmentid the /// closest real thing is consignment logs, which key on a consignmentid the
/// delivery flow does not always hold. /// delivery flow does not always hold.
static String get createDeliveryLog => url('/miler/consignments/logs'); static String get createDeliveryLog => url('/miler/consignments/logs');

View File

@@ -1,260 +1,46 @@
import 'dart:convert'; /// ─────────────────────────────────────────────────────────────────────────
import 'dart:io'; /// THE DELIVERY LINE'S RIDER LOG IS THE PARCEL LINE'S RIDER LOG
import 'package:http/http.dart';
import 'package:http/io_client.dart';
import 'package:flutter/foundation.dart';
// Combined Riderlog providers:
/// Hardcoded known-good IPs for hosts where carrier DNS returns broken nodes.
/// ///
/// **Empty since the delivery line moved to Doormile.** The one entry here /// ── What this file used to be ──
/// pinned `queue.workolik.com` to 66.116.225.226, because some Indian carriers'
/// DNS resolved that host to a CDN node that answered 404. Nothing calls that
/// host any more — every delivery-line write goes to `api.doormile.com` via
/// [DeliveryApi] — so the pin was routing advice about a server this app no
/// longer talks to.
/// ///
/// It is deliberately not re-pointed at the Doormile host: that IP belongs to /// A second, complete implementation of duty, breaks and the location
/// the old backend, and pinning it for `api.doormile.com` would send every /// heartbeat, posting to the two retired pre-v1 hosts over raw `http`. It survived the migration only because
/// request to the wrong server with the right SNI. If Doormile ever needs the /// nothing in `lib/` imported it: the delivery shell reaches it through
/// same workaround, add it here with its own verified address. /// [riderShell], which is production for any tenant on the delivery line.
const _knownGoodIPs = <String, String>{}; ///
/// It carried two things that had already been deleted from the parcel line:
///
/// • **`_buildSslBypassClient()`** — an `IOClient` whose
/// `badCertificateCallback` returned `true` for *every* certificate, with a
/// hand-rolled `SecureSocket` upgrade and a hardcoded IP pin. Every request
/// it carried — a rider's GPS track, his duty state, his break times — could
/// be read and rewritten by anything on the path. The parcel line removed
/// this; the delivery line kept it.
/// • **The legacy hosts themselves**, which the v1 migration left behind.
///
/// ── Why this is an export and not a rewrite ──
///
/// `lib/providers/Riderlog/riderlog_provider.dart` already does exactly this
/// job against `api.doormile.com`: same four class names, same legacy
/// `{status, details}` envelopes, mapped onto `POST /miler/duty/start`,
/// `PUT /miler/location`, `POST /miler/logs` and the break endpoints. The
/// delivery line's controller calls the same four classes by the same names.
///
/// So there is nothing to port. Re-exporting means one implementation, one duty
/// gate, one telemetry payload and one place where a contract change lands —
/// which is the whole point of having migrated the parcel line at all. The two
/// lines cannot drift, because there is only one of them.
///
/// The `String urldata` first parameter is gone from every method; callers that
/// were building a legacy URL to pass in have had that construction deleted
/// rather than made to compile.
/// ─────────────────────────────────────────────────────────────────────────
library;
/// Creates an IOClient that: export 'package:miler/providers/Riderlog/riderlog_provider.dart'
/// 1. Bypasses SSL certificate errors show
/// 2. Forces known-good IPs for hosts where carrier DNS returns broken CDN nodes BreakRiderLogProvider,
/// 3. Manually does TLS upgrade with correct SNI (hostname, not IP) CreateRiderLogProvider,
IOClient _buildSslBypassClient() { GetRiderLogProvider,
final httpClient = HttpClient() UpdateRiderLogProvider,
..badCertificateCallback = (X509Certificate cert, String host, int port) => unawaited;
true;
httpClient
.connectionFactory = (Uri uri, String? proxyHost, int? proxyPort) async {
final host = uri.host;
final port = uri.port;
// Use known-good IP if available, else resolve normally (prefer IPv4)
InternetAddress? target;
final knownIP = _knownGoodIPs[host];
if (knownIP != null) {
target = InternetAddress(knownIP);
debugPrint('[SSL_CLIENT] Using known-good IP: $knownIP for $host');
} else {
try {
final addresses = await InternetAddress.lookup(
host,
type: InternetAddressType.IPv4,
);
if (addresses.isNotEmpty) target = addresses.first;
} catch (_) {}
}
if (uri.scheme == 'https') {
final socketFuture = Socket.connect(target ?? InternetAddress(host), port)
.then(
(plain) => SecureSocket.secure(
plain,
host: host, // SNI = original hostname for Nginx routing
onBadCertificate: (_) => true,
supportedProtocols: ['http/1.1'],
),
)
.then((s) => s as Socket);
return ConnectionTask.fromSocket<Socket>(socketFuture, () {});
}
return Socket.startConnect(target ?? InternetAddress(host), port);
};
return IOClient(httpClient);
}
class CreateRiderLogProvider {
Future<Map<String, dynamic>?> createRiderLog(
String urldata,
Map<String, dynamic> data,
) async {
const maxAttempts = 3;
try {
debugPrint('createRiderLog payload ${json.encode(data)}');
} catch (_) {}
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
final client = _buildSslBypassClient();
try {
final url = Uri.parse(urldata);
final response = await client.post(
url,
body: json.encode(data),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
);
debugPrint('createRiderLog url $urldata (attempt $attempt)');
debugPrint('createRiderLog status ${response.statusCode}');
debugPrint('createRiderLog response ${response.body}');
if (response.statusCode >= 200 && response.statusCode < 300) {
return json.decode(response.body.toString()) as Map<String, dynamic>;
} else {
debugPrint(
'createRiderLog failed: HTTP ${response.statusCode} (attempt $attempt/$maxAttempts)',
);
// On 404/5xx, wait and retry to potentially hit a different CDN node
if (attempt < maxAttempts) {
await Future.delayed(const Duration(seconds: 1));
}
}
} catch (e) {
debugPrint('createRiderLog exception (attempt $attempt): $e');
if (attempt < maxAttempts) {
await Future.delayed(const Duration(seconds: 1));
}
} finally {
client.close();
}
}
debugPrint('createRiderLog failed after $maxAttempts attempts');
return null;
}
}
class UpdateRiderLogProvider {
Future<Map<String, dynamic>?> updateRiderLog(
String urldata,
Map<String, dynamic> data,
) async {
try {
final url = Uri.parse(urldata);
final response = await put(
url,
body: json.encode(data),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
);
debugPrint('updateRiderLog url: $urldata');
debugPrint('updateRiderLog response: ${response.body}');
if (response.statusCode >= 200 && response.statusCode < 300) {
final decoded = json.decode(response.body);
if (decoded is Map<String, dynamic>) {
return decoded;
} else {
debugPrint(
'⚠️ updateRiderLog: Expected Map but got ${decoded.runtimeType}',
);
return {};
}
} else {
debugPrint('❌ updateRiderLog failed with code ${response.statusCode}');
return {};
}
} catch (e) {
debugPrint('❌ Exception in updateRiderLog: $e');
return {};
}
}
}
class GetRiderLogProvider {
Future<Map<String, dynamic>?> getRiderLog(String urldata) async {
Map<String, dynamic>? getRiderLogResponse;
try {
final url = Uri.parse(urldata);
final response = await get(
url,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
);
debugPrint('getRiderLog response ${response.body}');
debugPrint('getRiderLog url ${urldata.toString()}');
getRiderLogResponse =
json.decode(response.body.toString()) as Map<String, dynamic>;
} catch (e) {
debugPrint(e.toString());
}
return getRiderLogResponse;
}
Future<Map<String, dynamic>?> getRiderCount(String urldata) async {
Map<String, dynamic>? getRiderCountResponse;
try {
final url = Uri.parse(urldata);
final response = await get(
url,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
);
debugPrint('getRiderCount response ${response.body}');
debugPrint('getRiderCount url ${urldata.toString()}');
getRiderCountResponse =
json.decode(response.body.toString()) as Map<String, dynamic>;
} catch (e) {
debugPrint(e.toString());
}
return getRiderCountResponse;
}
}
class BreakRiderLogProvider {
Future<Map<String, dynamic>?> createBreakRiderLog(
String urldata,
Map<String, dynamic> data,
) async {
Map<String, dynamic>? breakLogResponse;
try {
final url = Uri.parse(urldata);
final response = await post(
url,
body: json.encode(data),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
);
debugPrint('createBreakRiderLog url $urldata');
debugPrint('createBreakRiderLog response ${response.body}');
breakLogResponse =
json.decode(response.body.toString()) as Map<String, dynamic>;
} catch (e) {
debugPrint(e.toString());
}
return breakLogResponse;
}
Future<Map<String, dynamic>?> updateBreakRiderLog(
String urldata,
Map<String, dynamic> data,
) async {
Map<String, dynamic>? breakLogResponse;
try {
final url = Uri.parse(urldata);
final response = await put(
url,
body: json.encode(data),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
);
debugPrint('updateBreakRiderLog url $urldata');
debugPrint('updateBreakRiderLog response ${response.body}');
breakLogResponse =
json.decode(response.body.toString()) as Map<String, dynamic>;
} catch (e) {
debugPrint(e.toString());
}
return breakLogResponse;
}
}

View File

@@ -1,67 +1,25 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'package:http/http.dart' as http;
import 'package:http/http.dart'; import 'package:miler/data/api_config.dart';
import 'package:http/io_client.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
// Combined Deliverylog providers: // Combined Deliverylog providers:
/// Hardcoded known-good IPs for hosts where carrier DNS returns broken CDN nodes. /// ── The SSL bypass is gone ──
/// Confirmed: 66.116.225.226 = 200 OK, 125.21.240.67 = 404/405. ///
/// Empty since the delivery line moved to Doormile — see the note on the /// This file built its own `IOClient` with `onBadCertificate: (_) => true` and
/// matching map in `providers/Riderlog/riderlog_provider.dart`. The old entry /// a hand-rolled `SecureSocket` upgrade, so **every certificate was accepted**
/// pinned `queue.workolik.com`, a host nothing calls now, and must not be /// — a delivery rider's position, his order ids and his session were readable
/// re-pointed at `api.doormile.com`. /// and rewritable by anything on the path. The parcel line deleted the same
const _knownGoodIPs = <String, String>{}; /// code (see `providers/Riderlog/riderlog_provider.dart`); the delivery line
/// kept it because nothing in `lib/` imported this file directly.
/// Creates an IOClient that: ///
/// 1. Bypasses SSL certificate errors /// It also sent **no `Authorization` header**. The URLs it posts to are v1
/// 2. Forces known-good IPs to avoid broken CDN nodes from carrier DNS /// routes on `api.doormile.com` — resolved through the [DeliveryApi] facade —
/// 3. Manually does TLS upgrade with correct SNI (hostname, not IP) /// so every one of these calls could only ever have been answered with a 401.
IOClient _buildSslBypassClient() { /// A plain client plus [ApiConfig.authHeaders] fixes both at once.
final httpClient = HttpClient() Future<Map<String, String>> _headers() => ApiConfig.authHeaders();
..badCertificateCallback = (X509Certificate cert, String host, int port) =>
true;
httpClient
.connectionFactory = (Uri uri, String? proxyHost, int? proxyPort) async {
final host = uri.host;
final port = uri.port;
InternetAddress? target;
final knownIP = _knownGoodIPs[host];
if (knownIP != null) {
target = InternetAddress(knownIP);
} else {
try {
final addresses = await InternetAddress.lookup(
host,
type: InternetAddressType.IPv4,
);
if (addresses.isNotEmpty) target = addresses.first;
} catch (_) {}
}
if (uri.scheme == 'https') {
final socketFuture = Socket.connect(target ?? InternetAddress(host), port)
.then(
(plain) => SecureSocket.secure(
plain,
host: host,
onBadCertificate: (_) => true,
supportedProtocols: ['http/1.1'],
),
)
.then((s) => s as Socket);
return ConnectionTask.fromSocket<Socket>(socketFuture, () {});
}
return Socket.startConnect(target ?? InternetAddress(host), port);
};
return IOClient(httpClient);
}
class CreateDeliveryLogProvider { class CreateDeliveryLogProvider {
Future<Map<String, dynamic>?> createDeliveryLog( Future<Map<String, dynamic>?> createDeliveryLog(
@@ -70,19 +28,12 @@ class CreateDeliveryLogProvider {
bool wrapInArray = true, bool wrapInArray = true,
}) async { }) async {
Map<String, dynamic>? result; Map<String, dynamic>? result;
final client = _buildSslBypassClient(); final client = http.Client();
try { try {
final url = Uri.parse(urldata); final url = Uri.parse(urldata);
final body = json.encode(wrapInArray ? [data] : data); final body = json.encode(wrapInArray ? [data] : data);
final response = await client final response = await client
.post( .post(url, body: body, headers: await _headers())
url,
body: body,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
)
.timeout(const Duration(seconds: 10)); .timeout(const Duration(seconds: 10));
debugPrint('createDeliveryLog url $urldata'); debugPrint('createDeliveryLog url $urldata');
debugPrint(body); debugPrint(body);
@@ -110,18 +61,11 @@ class UpdateDeliveryProvider {
String urldata, String urldata,
) async { ) async {
Map<String, dynamic>? updateDeliveryResponse; Map<String, dynamic>? updateDeliveryResponse;
final client = _buildSslBypassClient(); final client = http.Client();
try { try {
final url = Uri.parse(urldata); final url = Uri.parse(urldata);
final response = await client final response = await client
.put( .put(url, body: json.encode(data), headers: await _headers())
url,
body: json.encode(data),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
)
.timeout(const Duration(seconds: 10)); .timeout(const Duration(seconds: 10));
debugPrint('updateDelivery url $urldata'); debugPrint('updateDelivery url $urldata');
debugPrint('updateDelivery status ${response.statusCode}'); debugPrint('updateDelivery status ${response.statusCode}');
@@ -148,18 +92,11 @@ class UpdateDeliveryProvider {
String urldata, String urldata,
) async { ) async {
Map<String, dynamic>? updateDeliveryResponse; Map<String, dynamic>? updateDeliveryResponse;
final client = _buildSslBypassClient(); final client = http.Client();
try { try {
final url = Uri.parse(urldata); final url = Uri.parse(urldata);
final response = await client final response = await client
.put( .put(url, body: json.encode(data), headers: await _headers())
url,
body: json.encode(data),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
)
.timeout(const Duration(seconds: 10)); .timeout(const Duration(seconds: 10));
debugPrint('updateArrived url $urldata'); debugPrint('updateArrived url $urldata');
debugPrint('updateArrived status ${response.statusCode}'); debugPrint('updateArrived status ${response.statusCode}');
@@ -185,18 +122,11 @@ class UpdateDeliveryProvider {
String urldata, String urldata,
) async { ) async {
Map<String, dynamic>? updateDeliveryResponse; Map<String, dynamic>? updateDeliveryResponse;
final client = _buildSslBypassClient(); final client = http.Client();
try { try {
final url = Uri.parse(urldata); final url = Uri.parse(urldata);
final response = await client final response = await client
.put( .put(url, body: json.encode(data), headers: await _headers())
url,
body: json.encode(data),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
)
.timeout(const Duration(seconds: 10)); .timeout(const Duration(seconds: 10));
debugPrint('updatePicked url $urldata'); debugPrint('updatePicked url $urldata');
debugPrint('updatePicked status ${response.statusCode}'); debugPrint('updatePicked status ${response.statusCode}');
@@ -222,18 +152,11 @@ class UpdateDeliveryProvider {
String urldata, String urldata,
) async { ) async {
Map<String, dynamic>? updateDeliveryResponse; Map<String, dynamic>? updateDeliveryResponse;
final client = _buildSslBypassClient(); final client = http.Client();
try { try {
final url = Uri.parse(urldata); final url = Uri.parse(urldata);
final response = await client final response = await client
.put( .put(url, body: json.encode(data), headers: await _headers())
url,
body: json.encode(data),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
)
.timeout(const Duration(seconds: 10)); .timeout(const Duration(seconds: 10));
debugPrint('updateActive url $urldata'); debugPrint('updateActive url $urldata');
debugPrint('updateActive status ${response.statusCode}'); debugPrint('updateActive status ${response.statusCode}');
@@ -258,7 +181,7 @@ class UpdateDeliveryProvider {
class GetDeliveryLogProvider { class GetDeliveryLogProvider {
Future<Map<String, dynamic>?> getDeliveryLog(String urldata) async { Future<Map<String, dynamic>?> getDeliveryLog(String urldata) async {
Map<String, dynamic>? result; Map<String, dynamic>? result;
final client = _buildSslBypassClient(); final client = http.Client();
try { try {
final url = Uri.parse(urldata); final url = Uri.parse(urldata);
final response = await client.get( final response = await client.get(

View File

@@ -34,6 +34,7 @@ import 'package:miler/xpress/views/Dashboard/deliveries/deliveries.dart'
as deliveries; as deliveries;
import 'package:miler/xpress/background/live_tracking_service.dart'; import 'package:miler/xpress/background/live_tracking_service.dart';
import 'package:miler/xpress/data/delivery_api.dart'; import 'package:miler/xpress/data/delivery_api.dart';
import 'package:miler/data/heartbeat.dart';
/// Helper function to launch phone dialer - works in both debug and release builds /// Helper function to launch phone dialer - works in both debug and release builds
/// In release builds, canLaunchUrl may fail due to R8/ProGuard, so we always try to launch /// In release builds, canLaunchUrl may fail due to R8/ProGuard, so we always try to launch
@@ -320,11 +321,13 @@ class _HomepageState extends State<Homepage>
Future<Duration> _getLogInterval() async { Future<Duration> _getLogInterval() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final secs = prefs.getInt('logseconds'); // Already correct — routed through the shared resolver so the 30 lives in
final int interval = (secs != null && secs > 0) ? secs : 30; // one place. See [kDefaultLogSeconds] for what a zero here used to cost
// the rider-log loop, which read the same pref and did not guard it.
final int interval = resolveLogSeconds(prefs.getInt('logseconds'));
return Duration(seconds: interval); return Duration(seconds: interval);
} catch (_) { } catch (_) {
return const Duration(seconds: 30); return const Duration(seconds: kDefaultLogSeconds);
} }
} }

View File

@@ -0,0 +1,353 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/device_telemetry.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/providers/Riderlog/riderlog_provider.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE TELEMETRY NEVER LEFT THE PHONE
///
/// ── What the dispatcher saw ──
///
/// Battery, Charging, Connection, GPS and Location Service, all blank, on
/// riders whose handsets were measuring every one of them correctly.
///
/// ── Where it actually stopped ──
///
/// Not in [DeviceTelemetry] — `device_telemetry_test.dart` pins that the
/// reading and the payload are right. Not in the callers, which all spread
/// `toPayload()` into the heartbeat. It stopped one layer further down, at a
/// gate in `riderlog_provider.dart`:
///
/// ```
/// if ((prefs.getInt('dutylogid') ?? 0) <= 0) return _startDuty(data);
/// return _heartbeat(data);
/// ```
///
/// `_heartbeat` is the **only** caller of `POST /miler/logs`, and also the only
/// caller of `PUT /miler/location`. Both were unreachable whenever `dutylogid`
/// was zero — and it stayed zero in two ordinary cases, permanently, with
/// nothing anywhere reporting an error:
///
/// • `duty/start` returning a body that does not name the id under that exact
/// key. The zero was then *stored*, so every later tick re-entered
/// `_startDuty`.
/// • `duty/start` answering 400 because the server already had the rider on
/// duty. The reconciliation through `duty/current` recovered the duty state
/// and wrote the id to `logid`/`logId` — never to `dutylogid`.
///
/// These tests drive the provider against a recording client, so what they
/// assert is what the phone would actually put on the wire.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
late List<http.Request> sent;
/// The heartbeat payload a caller builds — the real shape, from
/// `RiderLogController.createLoginNowV2` and the foreground service, both of
/// which spread `DeviceTelemetry.toPayload()` into it.
Map<String, dynamic> payload({int onduty = 1}) => {
'userid': 90001,
'latitude': '12.9716',
'longitude': '77.5946',
'speed': '0',
'heading': '0',
'accuracy': '8.5',
'status': 'active',
'onduty': onduty,
...const DeviceTelemetry(
battery: 72,
isCharging: false,
connection: 'wifi',
locationService: 'enabled',
).toPayload(),
'is_background': false,
};
/// Answers every route with a success envelope. [dutyStartBody] is what
/// `POST /miler/duty/start` returns, which is the variable the bug turned on.
void stub({
Map<String, dynamic> dutyStartBody = const {},
int dutyStartStatus = 200,
Map<String, dynamic> dutyCurrentBody = const {'onduty': true},
}) {
sent = <http.Request>[];
MilerApi.client = MockClient((req) async {
sent.add(req);
final path = req.url.path;
Map<String, dynamic> body = const {};
int status = 200;
if (path.endsWith('/duty/start')) {
body = dutyStartBody;
status = dutyStartStatus;
} else if (path.endsWith('/duty/current')) {
body = dutyCurrentBody;
}
return http.Response(
jsonEncode({'success': status < 400, 'data': body}),
status,
headers: const {'content-type': 'application/json'},
);
});
}
List<http.Request> to(String suffix) =>
sent.where((r) => r.url.path.endsWith(suffix)).toList();
Map<String, dynamic> bodyOf(http.Request r) =>
jsonDecode(r.body) as Map<String, dynamic>;
/// `POST /miler/logs` is deliberately fire-and-forget inside `_heartbeat` — a
/// telemetry failure must never read as a location failure, because the
/// caller treats the latter as duty state going wrong. So the request is in
/// flight, not finished, when the provider returns.
Future<void> settle() => pumpEventQueue();
setUp(() {
SharedPreferences.setMockInitialValues({});
stub();
});
tearDown(() => MilerApi.client = http.Client());
group('the telemetry reaches POST /miler/logs', () {
test(
'every field the console draws is on the wire, under its own name',
() async {
// Duty already established, so this is an ordinary heartbeat tick.
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
final logs = to('/miler/logs');
expect(logs, hasLength(1), reason: 'the heartbeat must post one log');
final body = bodyOf(logs.single);
// The names are the wire's. A rename is a console column that goes blank
// with nothing else failing.
expect(body['battery'], '72');
expect(body['is_charging'], false);
expect(body['connection'], 'wifi');
expect(body['location_service'], 'enabled');
expect(body['accuracy'], '8.5');
expect(body['is_background'], false);
expect(body['latitude'], '12.9716');
expect(body['longitude'], '77.5946');
},
);
test('and the location write still happens on the same tick', () async {
// `PUT /miler/location` is the Redis geo-index dispatch searches. It
// shares `_heartbeat` with the telemetry, so it was silenced by the same
// gate — and it must not be lost while fixing the other half.
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
expect(to('/miler/location'), hasLength(1));
expect(to('/miler/location').single.method, 'PUT');
});
test(
'REGRESSION · the log post completes before the heartbeat returns',
() async {
// ── Why this matters more than it looks ──
//
// `POST /miler/logs` was `unawaited(...)`, to keep a telemetry failure
// from reading as a location failure. On Android the heartbeat runs
// inside `flutter_foreground_task`'s own Flutter engine, spun up per
// tick — and when the callback returns, that engine can be suspended
// before an in-flight future finishes. So the awaited
// `PUT /miler/location` landed every tick and this post was killed
// mid-flight.
//
// The backend saw it before we did: a rider with a live position in
// Redis and no telemetry row behind it, while the app logged a
// successful heartbeat.
//
// Asserting *without* pumping the event queue is the whole point: if
// the post ever goes back to being fire-and-forget, the request has not
// been made yet at this line and this fails.
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
await CreateRiderLogProvider().createRiderLog(payload());
expect(
to('/miler/logs'),
hasLength(1),
reason: 'the telemetry post must finish before the tick returns',
);
},
);
test('no fix means no post, which is the contract, not a bug', () async {
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
await CreateRiderLogProvider().createRiderLog({
...payload(),
'latitude': '',
'longitude': '',
});
await settle();
expect(to('/miler/logs'), isEmpty);
expect(to('/miler/location'), isEmpty);
});
});
group('the gate that swallowed it', () {
test(
'REGRESSION · duty/start that does not name the id still heartbeats',
() async {
// The exact defect: an empty `data` body. The old code parsed 0, STORED
// 0, and every later tick re-entered `_startDuty` — so `/miler/logs`
// was never posted, on the rider's first shift, forever.
stub(dutyStartBody: const {});
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
expect(to('/miler/duty/start'), hasLength(1));
expect(
to('/miler/logs'),
hasLength(1),
reason: 'starting duty must not cost the rider the tick that did it',
);
// And the next tick is a plain heartbeat, not another start attempt.
final before = to('/miler/duty/start').length;
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
expect(to('/miler/duty/start'), hasLength(before));
expect(to('/miler/logs'), hasLength(2));
},
);
test(
'REGRESSION · "already on duty" reconciles AND opens the gate',
() async {
// `duty/start` 400s because the server already has him on duty — after
// a reinstall, cleared storage, or an `endDuty` that never landed. The
// reconciliation through `duty/current` used to write the id to
// `logid`/`logId` and not to `dutylogid`, so the gate stayed shut and
// the next tick came straight back here. Permanently.
stub(
dutyStartStatus: 400,
dutyCurrentBody: const {'onduty': true, 'dutylogid': 77},
);
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
expect(to('/miler/duty/current'), hasLength(1));
expect(
to('/miler/logs'),
hasLength(1),
reason: 'a reconciled rider must report, not loop on duty/start',
);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getInt('dutylogid'), 77);
// The loop is broken: no second start attempt.
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
expect(to('/miler/duty/start'), hasLength(1));
expect(to('/miler/logs'), hasLength(2));
},
);
test('the id is found however the response spells it', () async {
// One key on one level was the whole lookup. These are shapes this
// contract has worn; a miss is no longer fatal, but the legacy `logid`
// readers still want the number.
for (final key in ['dutylogid', 'dutyLogId', 'logid', 'id']) {
SharedPreferences.setMockInitialValues({});
stub(dutyStartBody: {key: 501});
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
final prefs = await SharedPreferences.getInstance();
expect(
prefs.getInt('dutylogid'),
501,
reason: 'the id was sent as `$key`',
);
}
});
test('an install from before the flag keeps reporting', () async {
// A rider mid-shift when he takes the update. He must not have to go off
// duty and on again to start reporting, and he must not re-start duty.
SharedPreferences.setMockInitialValues({'dutylogid': 909});
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
expect(to('/miler/duty/start'), isEmpty);
expect(to('/miler/logs'), hasLength(1));
});
});
group('the update path is gated the same way', () {
test(
'an on-duty update establishes duty and beats on the same tick',
() async {
stub(dutyStartBody: const {});
await UpdateRiderLogProvider().updateRiderLog(payload());
await settle();
expect(to('/miler/duty/start'), hasLength(1));
expect(to('/miler/logs'), hasLength(1));
},
);
test('going off duty ends it and stops the heartbeat', () async {
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
await UpdateRiderLogProvider().updateRiderLog({
...payload(onduty: 0),
'onduty': 0,
});
await settle();
expect(to('/miler/duty/end'), hasLength(1));
expect(
to('/miler/logs'),
isEmpty,
reason: 'an off-duty payload is not a heartbeat',
);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getInt('dutylogid'), 0);
// And the next tick starts duty again rather than beating into a shift
// that has ended.
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
expect(to('/miler/duty/start'), hasLength(1));
});
test('duty/current reporting off duty closes the gate', () async {
// The server is the authority. If it says the rider is off, the app must
// not keep beating on a stale local flag.
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
stub(dutyCurrentBody: const {'onduty': false});
await GetRiderLogProvider().getRiderLog();
await settle();
final prefs = await SharedPreferences.getInstance();
expect(prefs.getInt('onduty'), 0);
expect(prefs.getInt('dutylogid'), 0);
});
});
}

View File

@@ -0,0 +1,291 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/controllers/riderlog.dart';
import 'package:miler/data/heartbeat.dart';
import 'package:miler/data/mock_backend.dart';
import 'package:miler/providers/auth/auth_provider.dart';
import 'package:miler/xpress/controllers/riderlog.dart' as xpress;
/// ─────────────────────────────────────────────────────────────────────────
/// THE HEARTBEAT THAT NEVER STARTED
///
/// ── What the dispatcher saw ──
///
/// A rider on duty, and a console panel with an em dash against Battery,
/// Charging, Connection, GPS Accuracy and Location Service. The obvious
/// suspicion — that the phone was not measuring any of it — was wrong;
/// `device_telemetry_test.dart` already pins that the reader and the payload
/// are correct.
///
/// ── What was actually happening ──
///
/// One missing field, three call sites, and a zero that was read as an
/// instruction instead of as an absence:
///
/// 1. `POST /miler/verify-pin` on the v1 contract answers with
/// `{success, token, user:{…, profile:{…}}}`. There is no `logseconds` in
/// it — that field belonged to the legacy login.
/// 2. `AuthProvider.loginParsed` mapped the response into the legacy `details`
/// shape and persisted `details['logseconds'] ?? 0`, so every rider's
/// stored cadence was **zero**.
/// 3. `setOnDuty` started the loop only `if (interval > 0)`, and
/// `startAutoCreateLoginLoop` returned at `if (interval <= 0)`. Both gates
/// were shut, on every rider, on every shift.
///
/// The loop that did not start is the only thing that calls `_heartbeat()`,
/// and `_heartbeat()` is the only thing that writes **both** `POST /miler/logs`
/// (the trail the console's device columns are drawn from) and
/// `PUT /miler/location` (the geo-index dispatch searches to find a rider at
/// all). So the silence was not only cosmetic.
///
/// Nothing threw and nothing logged an error, which is why it survived so long:
/// the app reported duty on, posted exactly one log, and then went quiet for
/// the rest of the shift.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
group('resolveLogSeconds — a cadence has a floor, not a zero', () {
test('a real cadence from the hub is honoured exactly', () {
// The legacy backend does send this, and a hub that tunes it must win.
expect(resolveLogSeconds(15), 15);
expect(resolveLogSeconds(120), 120);
});
test('missing, zero and negative all mean "the backend did not say"', () {
// The three shapes the bug actually arrived in. Zero is the one that
// mattered: it is what `?? 0` wrote and what both gates read.
expect(resolveLogSeconds(null), kDefaultLogSeconds);
expect(resolveLogSeconds(0), kDefaultLogSeconds);
expect(resolveLogSeconds(-30), kDefaultLogSeconds);
});
test('a numeric string is a cadence too', () {
// `details['logseconds']` is untyped JSON and has been seen as both.
expect(resolveLogSeconds('45'), 45);
expect(resolveLogSeconds(' 45 '), 45);
expect(resolveLogSeconds('0'), kDefaultLogSeconds);
expect(resolveLogSeconds('later'), kDefaultLogSeconds);
expect(resolveLogSeconds(''), kDefaultLogSeconds);
});
test('the floor is never zero, whatever it is handed', () {
// The single property the whole fix rests on. Any return of 0 here puts
// the old `interval <= 0` behaviour back, whatever the call sites say.
for (final input in <Object?>[
null,
0,
-1,
'',
'x',
0.0,
-0.5,
<String>[],
]) {
expect(
resolveLogSeconds(input),
greaterThan(0),
reason: 'resolveLogSeconds($input) must never disable the heartbeat',
);
}
});
test('the default matches the cadence a live pickup forces', () {
// Both controllers hard-code 30 for the live-pickup case, and the pickup
// log's own interval falls back to 30. A different default here would
// give one rider's trail two shapes depending on what he was doing.
expect(kDefaultLogSeconds, 30);
});
});
group('the v1 login stores a cadence the loop can start on', () {
setUp(() async {
SharedPreferences.setMockInitialValues({});
MockBackend.enabled = true;
});
tearDown(() {
MockBackend.enabled = false;
});
test('verify-pin has no logseconds, so the adapter supplies the floor', () {
// The mock's verify-pin body is the real v1 shape, verified against a
// live 200 — `{success, token, user:{…, profile:{…}}}` and no cadence
// anywhere in it. That absence is the whole bug.
final canned = MockBackend.respond(
'POST',
'/miler/verify-pin',
body: const {'phone': '9000000001', 'pin': '1234'},
);
expect(canned, isNotNull);
expect(canned!.containsKey('logseconds'), isFalse);
expect((canned['user'] as Map).containsKey('logseconds'), isFalse);
});
test('the legacy details map carries 30, not 0', () async {
final res = await AuthProvider().login(
contactNo: '9000000001',
deviceType: 'android',
configId: 1,
deviceId: 'test-device',
fcmToken: '',
pinRaw: '1234',
);
final Map<String, dynamic> body = json.decode(res.body);
final Map<String, dynamic> details = body['details'];
// Read back by `loginParsed` and written to prefs. A zero here is the
// stored zero that shut both gates.
expect(details['logseconds'], kDefaultLogSeconds);
expect(details['logseconds'], greaterThan(0));
});
test(
'the stored cadence would pass even the gate that was removed',
() async {
// `setOnDuty` used to read this pref and compare it against zero. The
// assertion is deliberately written as the *old* condition: it is the
// one that decided whether a rider reported for his whole shift.
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(
'logseconds',
resolveLogSeconds(null), // what the v1 adapter now supplies
);
final int interval = resolveLogSeconds(prefs.getInt('logseconds'));
expect(interval > 0, isTrue);
expect(interval, 30);
},
);
});
group('the loop starts when the backend never sent a cadence', () {
setUp(() {
MockBackend.enabled = true;
});
tearDown(() {
MockBackend.enabled = false;
});
test('startAutoCreateLoginLoop runs with logseconds absent', () async {
// Exactly the state a v1 rider is in after signing in and going on duty:
// on duty, and no cadence stored at all.
SharedPreferences.setMockInitialValues({'onduty': 1});
final ctl = RiderLogController();
addTearDown(ctl.stopAutoCreateLoginLoop);
await ctl.startAutoCreateLoginLoop();
await pumpEventQueue();
// Before the fix this returned at `if (interval <= 0)` and left the
// subscription null — duty on, nothing reporting.
expect(
ctl.autoLoopRunning,
isTrue,
reason: 'a rider on duty must beat even when the hub set no cadence',
);
});
test(
'and with logseconds explicitly zero, which is what was stored',
() async {
SharedPreferences.setMockInitialValues({'onduty': 1, 'logseconds': 0});
final ctl = RiderLogController();
addTearDown(ctl.stopAutoCreateLoginLoop);
await ctl.startAutoCreateLoginLoop();
await pumpEventQueue();
expect(ctl.autoLoopRunning, isTrue);
},
);
test('off duty still does not beat', () async {
// The fix removes the cadence gate, not the duty gate. A rider who is
// off duty is not tracked, and that must stay true.
SharedPreferences.setMockInitialValues({'onduty': 0});
final ctl = RiderLogController();
addTearDown(ctl.stopAutoCreateLoginLoop);
await ctl.startAutoCreateLoginLoop();
await pumpEventQueue();
expect(ctl.autoLoopRunning, isFalse);
});
test('calling it again does not leave two loops running', () async {
// Three places call this — going on duty, resuming the app, and a pickup
// going live — and they overlap in practice. `stopAutoCreateLoginLoop`
// runs first, so the last caller wins and there is never a second
// subscription posting a duplicate trail.
SharedPreferences.setMockInitialValues({'onduty': 1});
final ctl = RiderLogController();
addTearDown(ctl.stopAutoCreateLoginLoop);
await ctl.startAutoCreateLoginLoop();
await pumpEventQueue();
expect(ctl.autoLoopRunning, isTrue);
// The live-pickup path, which passes its own cadence.
await ctl.startAutoCreateLoginLoop(seconds: 30);
await pumpEventQueue();
expect(ctl.autoLoopRunning, isTrue);
await ctl.stopAutoCreateLoginLoop();
expect(
ctl.autoLoopRunning,
isFalse,
reason: 'one stop must be enough to silence the heartbeat',
);
});
test('an explicit cadence from the caller is still honoured', () async {
SharedPreferences.setMockInitialValues({'onduty': 1});
final ctl = RiderLogController();
addTearDown(ctl.stopAutoCreateLoginLoop);
await ctl.startAutoCreateLoginLoop(seconds: 45);
await pumpEventQueue();
expect(ctl.autoLoopRunning, isTrue);
});
});
group('the meal line has the same fix', () {
setUp(() {
MockBackend.enabled = true;
});
tearDown(() {
MockBackend.enabled = false;
});
// `lib/xpress` is a parallel copy of the rider controller for the
// cloud-kitchen line, switched by tenant. It carried the identical two
// gates, so a meal-run rider was as silent as a logistics one.
test('xpress starts its loop with no cadence stored', () async {
SharedPreferences.setMockInitialValues({'onduty': 1});
final ctl = xpress.RiderLogController();
addTearDown(ctl.stopAutoCreateLoginLoop);
await ctl.startAutoCreateLoginLoop();
await pumpEventQueue();
expect(ctl.autoLoopRunning, isTrue);
});
test('xpress still respects duty', () async {
SharedPreferences.setMockInitialValues({'onduty': 0});
final ctl = xpress.RiderLogController();
addTearDown(ctl.stopAutoCreateLoginLoop);
await ctl.startAutoCreateLoginLoop();
await pumpEventQueue();
expect(ctl.autoLoopRunning, isFalse);
});
});
}

View File

@@ -0,0 +1,251 @@
import 'dart:convert';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/device_telemetry.dart';
import 'package:miler/data/heartbeat.dart';
import 'package:miler/data/miler_api.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE `POST /miler/logs` CONTRACT, PINNED FIELD BY FIELD
///
/// The gate that stopped the heartbeat reaching this endpoint is fixed
/// elsewhere (`duty_heartbeat_gate_test.dart`). This is the layer under it: of
/// the requests that *do* go out, are they in the shape the handler parses?
///
/// Four of these were wrong, and every one of them fails silently. Nothing 400s
/// in a way the app surfaces, the row is written or dropped server-side, and
/// the only symptom is a console panel that is blank or stale:
///
/// 1. **`logdate` was IST wall-clock.** The handler parses it as UTC and uses
/// it as a sort score, so every point from an Indian handset was stamped
/// +05:30 into the future — outside any recent-window the console asks for,
/// and filed under tomorrow after 18:30 local.
/// 2. **`status` was `active` / `idle`.** Neither word appears in the API. The
/// field takes an availability value, and this backend drops unrecognised
/// enums silently.
/// 3. **`connection` was the plugin's enum constant** — `wifi`, `mobile`,
/// `ethernet` — not the contract's `WiFi` / `4G` / `none`.
/// 4. **`location_service` could be `denied` or `denied_forever`**, which the
/// field does not define.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('logdate is the phone\'s local wall clock', () {
// ── Reverted from UTC, on the backend team's correction ──
//
// UTC was the intuitive answer for a field with no zone suffix, and it is
// the wrong one for this backend: `DBNow` / `DBToday` and the database
// itself run on IST wall-clock. A UTC stamp from an Indian handset lands
// 5h30m behind the console's window, and after 18:30 local it files the
// evening under the previous day.
//
// Written against local fields rather than a fixed string, so the suite
// passes wherever it runs — including CI on a UTC host, which is precisely
// the machine that would not have caught the original mistake.
String two(int n) => n.toString().padLeft(2, '0');
test('a local DateTime is rendered exactly as its own wall clock', () {
final at = DateTime(2026, 8, 27, 21, 30, 0);
expect(MilerApi.logStamp(at), '2026-08-27 21:30:00');
});
test('a UTC instant is converted to local, not printed as UTC', () {
final utc = DateTime.utc(2026, 8, 27, 16, 0, 0);
final local = utc.toLocal();
expect(
MilerApi.logStamp(utc),
'${local.year}-${two(local.month)}-${two(local.day)} '
'${two(local.hour)}:${two(local.minute)}:${two(local.second)}',
);
});
test('the evening does not roll into the previous day', () {
// The case UTC actually broke: 21:30 IST is 16:00 UTC the same day, but
// 00:30 IST is 19:00 UTC the day *before*. The date must follow the
// rider's clock, because that is the day the console groups by.
final at = DateTime(2026, 8, 27, 23, 45, 0);
expect(MilerApi.logStamp(at), '2026-08-27 23:45:00');
});
test('the format is what the handler parses, with no zone suffix', () {
final stamp = MilerApi.logStamp(DateTime(2026, 1, 2, 3, 4, 5));
expect(stamp, '2026-01-02 03:04:05');
expect(stamp, matches(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'));
});
});
group('status is an availability value', () {
test('both values the heartbeat can send are in the enum', () {
for (final hasWork in [true, false]) {
final status = heartbeatStatus(hasActiveWork: hasWork);
expect(
MilerApi.availabilityStatuses,
contains(status),
reason: '`$status` is not a value the backend accepts',
);
}
});
test('the words that were there before are not in the enum', () {
// Pinned so the old strings cannot come back through a merge.
expect(MilerApi.availabilityStatuses, isNot(contains('active')));
expect(MilerApi.availabilityStatuses, isNot(contains('idle')));
});
test('a rider at a counter and a rider between stops differ', () {
expect(heartbeatStatus(hasActiveWork: true), 'On_Pickup');
expect(heartbeatStatus(hasActiveWork: false), 'Available');
});
});
group('the telemetry vocabulary is the contract, not the plugin', () {
test('connection is WiFi / 4G / none', () {
const t = DeviceTelemetry(connection: 'WiFi');
expect(t.toPayload()['connection'], 'WiFi');
});
test('location_service is only ever enabled or disabled', () async {
// `read` collapses the richer answers the fix lookup produces. The
// distinction between off and denied still rides on the MQTT alert.
for (final raw in ['denied', 'denied_forever', 'unknown', 'disabled']) {
final t = await DeviceTelemetry.read(locationService: raw);
final v = t.toPayload()['location_service'];
expect(
v,
anyOf(isNull, 'enabled', 'disabled'),
reason: '`$raw` must not reach the wire as itself',
);
expect(v, isNot('denied'));
}
});
test('enabled survives, because it is the one that means yes', () async {
final t = await DeviceTelemetry.read(locationService: 'enabled');
expect(t.toPayload()['location_service'], 'enabled');
});
});
group('the request on the wire', () {
late List<http.Request> sent;
setUp(() {
SharedPreferences.setMockInitialValues({});
sent = <http.Request>[];
MilerApi.client = MockClient((req) async {
sent.add(req);
return http.Response(
jsonEncode({'success': true, 'data': {}}),
200,
headers: const {'content-type': 'application/json'},
);
});
});
tearDown(() => MilerApi.client = http.Client());
Future<Map<String, dynamic>> post() async {
await MilerApi.postLog(
latitude: 12.9716,
longitude: 77.5946,
speed: 4.2,
heading: 91.0,
accuracy: 8.5,
status: heartbeatStatus(hasActiveWork: true),
orderId: 25,
battery: 72,
isCharging: true,
connection: 'WiFi',
locationService: 'enabled',
isBackground: true,
);
return jsonDecode(sent.single.body) as Map<String, dynamic>;
}
test('every numeric field is a string', () async {
final body = await post();
for (final key in [
'latitude',
'longitude',
'speed',
'heading',
'accuracy',
'battery',
]) {
expect(
body[key],
isA<String>(),
reason: '`$key` must be a string — real numbers fail to parse',
);
}
});
test('is_charging and is_background stay real booleans', () async {
final body = await post();
expect(body['is_charging'], isA<bool>());
expect(body['is_background'], isA<bool>());
expect(body['is_charging'], isTrue);
expect(body['is_background'], isTrue);
});
test('userid is never sent — the token owns it', () async {
final body = await post();
expect(body.containsKey('userid'), isFalse);
expect(body.containsKey('userId'), isFalse);
});
test(
'one point per call, because the handler parses one MilerLog',
() async {
await post();
expect(sent, hasLength(1));
expect(jsonDecode(sent.single.body), isA<Map<String, dynamic>>());
expect(
jsonDecode(sent.single.body),
isNot(isA<List>()),
reason: 'this endpoint is not the batched one',
);
},
);
test('the whole documented field set is present', () async {
final body = await post();
expect(
body.keys,
containsAll(<String>[
'logdate',
'latitude',
'longitude',
'speed',
'heading',
'accuracy',
'status',
'orderid',
'battery',
'is_charging',
'connection',
'location_service',
'is_background',
]),
);
});
});
group('the connectivity mapping', () {
// Driven through `read` because the mapper is private; on the test host
// the plugin is absent, so this pins the shape rather than the wiring.
test('the enum this plugin version returns is a list, not a scalar', () {
// connectivity_plus 6+ reports every active transport. Reading `.first`
// made a phone on Wi-Fi with mobile data up report either one, depending
// on the order the platform happened to return them.
expect(ConnectivityResult.values, contains(ConnectivityResult.wifi));
expect(ConnectivityResult.values, contains(ConnectivityResult.mobile));
expect(ConnectivityResult.values, contains(ConnectivityResult.none));
});
});
}