updated the sheet
19
.gitignore
vendored
@@ -49,3 +49,22 @@ app.*.map.json
|
||||
# every failing run — 128 of them were sitting untracked. The references
|
||||
# themselves live in test/shots and ARE committed; these are the diff output.
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
`com.doormile.partner` · Flutter · GetX for the controllers that need it ·
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<!-- ===== Application ===== -->
|
||||
<!-- usesCleartextTraffic was `true`, commented "allows http if any". There
|
||||
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
|
||||
certificates.
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
the certificate bypass in MyHttpOverrides, which was the other half
|
||||
of the same hole. -->
|
||||
<application
|
||||
android:label="Doormile Rider"
|
||||
android:label="Miler"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="false">
|
||||
|
||||
@@ -1,278 +1,232 @@
|
||||
package com.doormile.partner
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* BroadcastReceiver that triggers when shift end alarm fires
|
||||
* This works even when app is killed
|
||||
*
|
||||
* When app is killed, we directly call the API to create break log
|
||||
* since we can't reliably start Flutter engine
|
||||
*/
|
||||
class ShiftEndReceiver : BroadcastReceiver() {
|
||||
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)}")
|
||||
|
||||
// ✅ CRITICAL: Acquire WakeLock to keep device awake during API call
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ShiftEndReceiver::WakeLock")
|
||||
wakeLock.acquire(60 * 1000L) // Hold for 60 seconds max
|
||||
|
||||
// Execute API call in background thread
|
||||
Thread {
|
||||
try {
|
||||
// Check if rider is still on duty
|
||||
val prefs: SharedPreferences = context.getSharedPreferences(
|
||||
"FlutterSharedPreferences",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
// ✅ Try multiple key formats (Flutter SharedPreferences uses "flutter." prefix)
|
||||
val onduty = prefs.getInt("flutter.onduty", 0)
|
||||
Log.d("ShiftEndReceiver", "Checking onduty: $onduty")
|
||||
|
||||
if (onduty != 1) {
|
||||
Log.d("ShiftEndReceiver", "Rider already offline (onduty=$onduty), skipping break log creation")
|
||||
return@Thread
|
||||
}
|
||||
|
||||
// Get required IDs - try both flutter.userid and flutter.userId
|
||||
val userid = prefs.getInt("flutter.userid", 0).takeIf { it != 0 }
|
||||
?: prefs.getInt("flutter.userId", 0)
|
||||
val partnerid = prefs.getInt("flutter.partnerid", 0).takeIf { it != 0 }
|
||||
?: prefs.getInt("flutter.partnerId", 0)
|
||||
val shiftid = prefs.getInt("flutter.shiftid", 0).takeIf { it != 0 }
|
||||
?: prefs.getInt("flutter.shiftId", 0)
|
||||
val logid = prefs.getInt("flutter.logid", 0).takeIf { it != 0 }
|
||||
?: prefs.getInt("flutter.logId", 0)
|
||||
|
||||
Log.d("ShiftEndReceiver", "Retrieved IDs: userid=$userid, partnerid=$partnerid, shiftid=$shiftid, logid=$logid")
|
||||
|
||||
if (userid == 0) {
|
||||
Log.e("ShiftEndReceiver", "❌ Missing userid, cannot create break log. Available keys: ${prefs.all.keys}")
|
||||
return@Thread
|
||||
}
|
||||
|
||||
// Get API base URL (check if live or dev)
|
||||
// ✅ Match homepage logic: use createbreaklog (not createbreakriderlog)
|
||||
val mainRoute = prefs.getString("flutter.mainRoute", "dev") ?: "dev"
|
||||
val isLive = mainRoute == "live"
|
||||
Log.d("ShiftEndReceiver", "API Route: $mainRoute (isLive=$isLive)")
|
||||
val baseUrl = if (isLive) {
|
||||
"https://jupiter.doormile.app/live/api/v2/partners/createbreaklog"
|
||||
} else {
|
||||
"https://jupiter.doormile.app/dev/api/v2/partners/createbreaklog"
|
||||
}
|
||||
|
||||
// Create break log payload
|
||||
val now = Calendar.getInstance()
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.US)
|
||||
val breakdate = dateFormat.format(now.time)
|
||||
val breakstart = timeFormat.format(now.time)
|
||||
val localBreakId = (System.currentTimeMillis() % 900).toInt() + 100
|
||||
|
||||
// ✅ 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"}"""
|
||||
|
||||
Log.d("ShiftEndReceiver", "📤 Sending break log request to: $baseUrl")
|
||||
Log.d("ShiftEndReceiver", "📦 Payload: $payload")
|
||||
|
||||
// Make API call
|
||||
val url = URL(baseUrl)
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "POST"
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.doOutput = true
|
||||
connection.doInput = true
|
||||
connection.useCaches = false
|
||||
connection.connectTimeout = 15000 // Increased timeout
|
||||
connection.readTimeout = 15000
|
||||
|
||||
Log.d("ShiftEndReceiver", "🔌 Connecting to API...")
|
||||
|
||||
// Write payload
|
||||
val outputStream = connection.outputStream
|
||||
val writer = OutputStreamWriter(outputStream, "UTF-8")
|
||||
writer.write(payload)
|
||||
writer.flush()
|
||||
writer.close()
|
||||
|
||||
Log.d("ShiftEndReceiver", "📨 Request sent, waiting for response...")
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
Log.d("ShiftEndReceiver", "📥 Break log API response: HTTP $responseCode")
|
||||
|
||||
// Read response body for debugging
|
||||
try {
|
||||
val responseStream = if (responseCode in 200..299) {
|
||||
connection.inputStream
|
||||
} else {
|
||||
connection.errorStream
|
||||
}
|
||||
if (responseStream != null) {
|
||||
val responseBody = responseStream.bufferedReader().use { it.readText() }
|
||||
Log.d("ShiftEndReceiver", "📄 Response body: $responseBody")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("ShiftEndReceiver", "Could not read response body: ${e.message}")
|
||||
}
|
||||
|
||||
if (responseCode in 200..299) {
|
||||
Log.d("ShiftEndReceiver", "✅ Break log created successfully (HTTP $responseCode)")
|
||||
|
||||
// Set offline locally
|
||||
prefs.edit()
|
||||
.putInt("flutter.onduty", 0)
|
||||
.putBoolean("flutter.online", false)
|
||||
.apply()
|
||||
|
||||
// Also update rider log to set onduty=0
|
||||
val updateUrl = if (isLive) {
|
||||
"https://jupiter.doormile.app/live/api/v2/partners/updateriderlog"
|
||||
} else {
|
||||
"https://jupiter.doormile.app/dev/api/v2/partners/updateriderlog"
|
||||
}
|
||||
|
||||
// ✅ Compact JSON (no extra whitespace)
|
||||
val updatePayload = """{"userid":$userid,"onduty":0,"latitude":"0","longitude":"0"}"""
|
||||
|
||||
Log.d("ShiftEndReceiver", "📤 Updating rider status to offline")
|
||||
|
||||
try {
|
||||
val updateConnection = URL(updateUrl).openConnection() as HttpURLConnection
|
||||
updateConnection.requestMethod = "POST"
|
||||
updateConnection.setRequestProperty("Content-Type", "application/json")
|
||||
updateConnection.doOutput = true
|
||||
updateConnection.connectTimeout = 5000
|
||||
updateConnection.readTimeout = 5000
|
||||
|
||||
val updateWriter = OutputStreamWriter(updateConnection.outputStream, "UTF-8")
|
||||
updateWriter.write(updatePayload)
|
||||
updateWriter.flush()
|
||||
updateWriter.close()
|
||||
|
||||
if (updateConnection.responseCode in 200..299) {
|
||||
Log.d("ShiftEndReceiver", "✅ Rider status updated to Offline")
|
||||
}
|
||||
updateConnection.disconnect()
|
||||
} catch (e: Exception) {
|
||||
Log.e("ShiftEndReceiver", "Error updating rider status: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
// Read error response for debugging
|
||||
try {
|
||||
val errorStream = connection.errorStream
|
||||
if (errorStream != null) {
|
||||
val errorResponse = errorStream.bufferedReader().use { it.readText() }
|
||||
Log.e("ShiftEndReceiver", "❌ Failed to create break log: HTTP $responseCode\nError: $errorResponse")
|
||||
} else {
|
||||
Log.e("ShiftEndReceiver", "❌ Failed to create break log: HTTP $responseCode")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ShiftEndReceiver", "❌ Failed to create break log: HTTP $responseCode (Error reading response: ${e.message})")
|
||||
}
|
||||
}
|
||||
|
||||
connection.disconnect()
|
||||
|
||||
// ✅ 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 {
|
||||
val endTimeStr = prefs.getString("flutter.endtime", "") ?: ""
|
||||
val startTimeStr = prefs.getString("flutter.starttime", "") ?: ""
|
||||
Log.d("ShiftEndReceiver", "Rescheduling alarm - endTime: $endTimeStr, startTime: $startTimeStr")
|
||||
|
||||
if (endTimeStr.isNotEmpty()) {
|
||||
// Schedule alarm for tomorrow at the same time
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = Intent(context, ShiftEndReceiver::class.java).apply {
|
||||
action = "com.doormile.partner.SHIFT_END_ALARM"
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
1001,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val timeParts = endTimeStr.split(":")
|
||||
if (timeParts.size >= 2) {
|
||||
val endHour = timeParts[0].toIntOrNull() ?: 0
|
||||
val endMinute = timeParts[1].toIntOrNull() ?: 0
|
||||
|
||||
val calendar = Calendar.getInstance().apply {
|
||||
add(Calendar.DAY_OF_MONTH, 1) // Tomorrow
|
||||
set(Calendar.HOUR_OF_DAY, endHour)
|
||||
set(Calendar.MINUTE, endMinute)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
||||
if (alarmManager.canScheduleExactAlarms()) {
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
calendar.timeInMillis,
|
||||
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()
|
||||
}
|
||||
}
|
||||
package com.doormile.partner
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* BroadcastReceiver that triggers when shift end alarm fires
|
||||
* This works even when app is killed
|
||||
*
|
||||
* When app is killed, we directly call the API to create break log
|
||||
* since we can't reliably start Flutter engine
|
||||
*/
|
||||
class ShiftEndReceiver : BroadcastReceiver() {
|
||||
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)}")
|
||||
|
||||
// ✅ CRITICAL: Acquire WakeLock to keep device awake during API call
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ShiftEndReceiver::WakeLock")
|
||||
wakeLock.acquire(60 * 1000L) // Hold for 60 seconds max
|
||||
|
||||
// Execute API call in background thread
|
||||
Thread {
|
||||
try {
|
||||
// Check if rider is still on duty
|
||||
val prefs: SharedPreferences = context.getSharedPreferences(
|
||||
"FlutterSharedPreferences",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
// ✅ Try multiple key formats (Flutter SharedPreferences uses "flutter." prefix)
|
||||
val onduty = prefs.getInt("flutter.onduty", 0)
|
||||
Log.d("ShiftEndReceiver", "Checking onduty: $onduty")
|
||||
|
||||
if (onduty != 1) {
|
||||
Log.d("ShiftEndReceiver", "Rider already offline (onduty=$onduty), skipping break log creation")
|
||||
return@Thread
|
||||
}
|
||||
|
||||
// Get required IDs - try both flutter.userid and flutter.userId
|
||||
val userid = prefs.getInt("flutter.userid", 0).takeIf { it != 0 }
|
||||
?: prefs.getInt("flutter.userId", 0)
|
||||
|
||||
if (userid == 0) {
|
||||
Log.e("ShiftEndReceiver", "❌ Missing userid, cannot create break log. Available keys: ${prefs.all.keys}")
|
||||
return@Thread
|
||||
}
|
||||
|
||||
// ── v1, with the session, and no ids in the body ──
|
||||
//
|
||||
// This block hand-posted two legacy routes over HttpURLConnection:
|
||||
// partners/createbreaklog and partners/updateriderlog, on
|
||||
// the retired pre-v1 backend,
|
||||
// with `userid`, `partnerid`, `shiftid` and `logid` in the body and
|
||||
// NO Authorization header — an unauthenticated write keyed on ids
|
||||
// read out of SharedPreferences.
|
||||
//
|
||||
// The v1 equivalents key on the bearer token instead: the server
|
||||
// knows which rider the session names, so the ids are neither sent
|
||||
// nor needed. Same two effects, one host, one auth model as the
|
||||
// rest of the app.
|
||||
//
|
||||
// No token means no shift to end. That is the correct outcome —
|
||||
// a signed-out handset must not be able to close somebody's duty.
|
||||
val token = prefs.getString("flutter.authtoken", "") ?: ""
|
||||
if (token.isEmpty()) {
|
||||
Log.e("ShiftEndReceiver", "No session token; cannot end shift")
|
||||
return@Thread
|
||||
}
|
||||
|
||||
val breakOk = callV1(
|
||||
path = "/miler/breaks/start",
|
||||
method = "POST",
|
||||
token = token,
|
||||
body = """{"breaktype":"Shift_End"}"""
|
||||
)
|
||||
|
||||
if (breakOk) {
|
||||
Log.d("ShiftEndReceiver", "Break log created successfully")
|
||||
|
||||
prefs.edit()
|
||||
.putInt("flutter.onduty", 0)
|
||||
.putBoolean("flutter.online", false)
|
||||
.apply()
|
||||
|
||||
// `PUT /miler/duty/end` takes no body and closes whichever duty
|
||||
// log is open for the token's rider.
|
||||
if (callV1("/miler/duty/end", "PUT", token, null)) {
|
||||
Log.d("ShiftEndReceiver", "Rider status updated to Offline")
|
||||
}
|
||||
} else {
|
||||
Log.e("ShiftEndReceiver", "Failed to create break log")
|
||||
}
|
||||
|
||||
// ✅ 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 {
|
||||
val endTimeStr = prefs.getString("flutter.endtime", "") ?: ""
|
||||
val startTimeStr = prefs.getString("flutter.starttime", "") ?: ""
|
||||
Log.d("ShiftEndReceiver", "Rescheduling alarm - endTime: $endTimeStr, startTime: $startTimeStr")
|
||||
|
||||
if (endTimeStr.isNotEmpty()) {
|
||||
// Schedule alarm for tomorrow at the same time
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = Intent(context, ShiftEndReceiver::class.java).apply {
|
||||
action = "com.doormile.partner.SHIFT_END_ALARM"
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
1001,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val timeParts = endTimeStr.split(":")
|
||||
if (timeParts.size >= 2) {
|
||||
val endHour = timeParts[0].toIntOrNull() ?: 0
|
||||
val endMinute = timeParts[1].toIntOrNull() ?: 0
|
||||
|
||||
val calendar = Calendar.getInstance().apply {
|
||||
add(Calendar.DAY_OF_MONTH, 1) // Tomorrow
|
||||
set(Calendar.HOUR_OF_DAY, endHour)
|
||||
set(Calendar.MINUTE, endMinute)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
||||
if (alarmManager.canScheduleExactAlarms()) {
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
calendar.timeInMillis,
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* One authenticated call to the v1 backend. Returns true on 2xx.
|
||||
*
|
||||
* The single place this file talks to a network, so there is one base URL,
|
||||
* one auth model and one timeout policy rather than a copy per endpoint.
|
||||
*/
|
||||
private fun callV1(path: String, method: String, token: String, body: String?): Boolean {
|
||||
var connection: HttpURLConnection? = null
|
||||
return try {
|
||||
connection = URL(V1_BASE + path).openConnection() as HttpURLConnection
|
||||
connection.requestMethod = method
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("Authorization", "Bearer $token")
|
||||
connection.connectTimeout = 15000
|
||||
connection.readTimeout = 15000
|
||||
if (body != null) {
|
||||
connection.doOutput = true
|
||||
OutputStreamWriter(connection.outputStream, "UTF-8").use {
|
||||
it.write(body)
|
||||
it.flush()
|
||||
}
|
||||
}
|
||||
val code = connection.responseCode
|
||||
Log.d("ShiftEndReceiver", "$method $path -> HTTP $code")
|
||||
code in 200..299
|
||||
} catch (e: Exception) {
|
||||
Log.e("ShiftEndReceiver", "$method $path failed: ${e.message}")
|
||||
false
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The one backend this app talks to. */
|
||||
private const val V1_BASE = "https://api.doormile.com/api/v1"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
663
android/build/reports/problems/problems-report.html
Normal file
@@ -1,4 +1,4 @@
|
||||
storePassword=123456789
|
||||
keyPassword=123456789
|
||||
keyAlias= doormile
|
||||
storeFile=doormilerider-keystore.jks
|
||||
storePassword=W8UzA5NVaDTvix79rekmn8834m81tcKX
|
||||
keyPassword=W8UzA5NVaDTvix79rekmn8834m81tcKX
|
||||
keyAlias=doormile
|
||||
storeFile=doormilerider-keystore.jks
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
@@ -3,15 +3,15 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_tts/flutter_tts.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
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/providers/notifications/notificationservce.dart';
|
||||
import 'package:miler/views/helpers/constants/apiconstants.dart';
|
||||
import 'package:miler/utils/kalman_filter.dart';
|
||||
|
||||
/// Background service for managing active pickup logs
|
||||
@@ -20,7 +20,6 @@ class BackgroundCollectionLog {
|
||||
static DateTime? _lastUpdateTime;
|
||||
|
||||
static final CreatePickupLogProvider _logProvider = CreatePickupLogProvider();
|
||||
static final http.Client _httpClient = http.Client();
|
||||
static final FlutterTts _tts = FlutterTts();
|
||||
static final AudioPlayer _proximityPlayer = AudioPlayer();
|
||||
|
||||
@@ -60,38 +59,39 @@ class BackgroundCollectionLog {
|
||||
final now = DateTime.now();
|
||||
final today = '${now.year}-${_pad(now.month)}-${_pad(now.day)}';
|
||||
|
||||
// Build API URL using v1 endpoint (getpickup)
|
||||
final bool isLive = ApiConstants.mainRoute == 'live';
|
||||
final baseUrl = isLive
|
||||
? ApiConstants.currentPickupLive
|
||||
: ApiConstants.currentPickupDev;
|
||||
|
||||
final uri = Uri.parse(baseUrl).replace(
|
||||
queryParameters: {
|
||||
'userid': userId.toString(),
|
||||
'fromdate': today,
|
||||
'todate': today,
|
||||
't': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('[ACTIVE_COLLECTION_LOG][BG] Fetching Pickup: $uri');
|
||||
|
||||
// Fetch pickup from API using v1 endpoint
|
||||
final pickups = await _fetchPickupsFromApi(uri);
|
||||
// ── This asked the pre-v1 backend, with the wrong question ──
|
||||
//
|
||||
// It built a `pickups/getpickups` URL on the retired backend by hand
|
||||
// and fetched it with a bare `_httpClient.get` — **no bearer token**, and
|
||||
// `userid` in the query string, so the rider's day was readable by id
|
||||
// from an unauthenticated call.
|
||||
//
|
||||
// It could never have worked anyway. The filter below looked for
|
||||
// `orderstatus == 'active'`, and `active` is not a value this app's own
|
||||
// vocabulary contains: [ApiConfig.legacyStatusFromNew] maps the v1
|
||||
// statuses to `assigned` / `accepted` / `arrived` / `picked`, and never to
|
||||
// `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
|
||||
// the rider the token names. The filter is the real vocabulary: work the
|
||||
// rider has taken on and not yet collected.
|
||||
final res = await MilerApi.bookings(date: today);
|
||||
final pickups = res.list;
|
||||
|
||||
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((
|
||||
Booking,
|
||||
) {
|
||||
final status = (Booking['orderstatus']?.toString().toLowerCase() ?? '')
|
||||
.trim();
|
||||
final isActive = status == 'active';
|
||||
return isActive;
|
||||
final status = ApiConfig.legacyStatusFromNew(
|
||||
(Booking['status'] ?? Booking['orderstatus'])?.toString(),
|
||||
);
|
||||
return inProgress.contains(status);
|
||||
}).toList();
|
||||
|
||||
if (activeOrders.isEmpty) {
|
||||
@@ -189,7 +189,9 @@ class BackgroundCollectionLog {
|
||||
// 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
|
||||
// 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
|
||||
payloadWithCoords = <String, dynamic>{
|
||||
@@ -211,11 +213,6 @@ class BackgroundCollectionLog {
|
||||
'logstatus': await _resolveLogStatus(orderId, coords),
|
||||
};
|
||||
|
||||
// Determine API endpoint
|
||||
final url = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.createPickupLogLive
|
||||
: ApiConstants.createPickupLogDev;
|
||||
|
||||
// Post the log
|
||||
debugPrint('[ACTIVE_COLLECTION_LOG][BG] Posting pickup log for $orderId');
|
||||
|
||||
@@ -427,33 +424,6 @@ class BackgroundCollectionLog {
|
||||
}
|
||||
|
||||
/// 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
|
||||
static String _pad(int n) => n.toString().padLeft(2, '0');
|
||||
@@ -533,10 +503,6 @@ class BackgroundCollectionLog {
|
||||
final List<String> remaining = [];
|
||||
bool anySuccess = false;
|
||||
|
||||
final url = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.createPickupLogLive
|
||||
: ApiConstants.createPickupLogDev;
|
||||
|
||||
for (final itemStr in queue) {
|
||||
try {
|
||||
final Map<String, dynamic> item = jsonDecode(itemStr);
|
||||
@@ -761,14 +727,18 @@ class BackgroundCollectionLog {
|
||||
'[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 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) {
|
||||
debugPrint(
|
||||
'[AUTO_SHIFT_END] ❌ Missing userid, cannot create break log',
|
||||
@@ -776,61 +746,28 @@ class BackgroundCollectionLog {
|
||||
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 int localBreakId =
|
||||
(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 =
|
||||
'${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}';
|
||||
|
||||
final payload = <String, dynamic>{
|
||||
"breakid": localBreakId,
|
||||
"logid": logid,
|
||||
"breakdate": breakdate,
|
||||
"userid": userid,
|
||||
"partnerid": partnerid,
|
||||
"shiftid": shiftid,
|
||||
"breakstart": breakstart,
|
||||
"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.
|
||||
// 4. Close the shift with a break, on v1.
|
||||
//
|
||||
// This hand-posted the retired backend's `partners/createbreaklog`
|
||||
// with a bare `_httpClient.post` and no bearer
|
||||
// token — an unauthenticated write keyed on a `userid` in the
|
||||
// body. `POST /miler/breaks/start` is the v1 endpoint, and
|
||||
// [MilerApi] attaches the session.
|
||||
debugPrint('[AUTO_SHIFT_END] Creating break log (v1)');
|
||||
|
||||
try {
|
||||
final response = await _httpClient
|
||||
.post(
|
||||
Uri.parse(url),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(payload),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
final res = await MilerApi.startBreak('Shift_End');
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
if (res.ok) {
|
||||
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 body = jsonDecode(response.body);
|
||||
final det = (body['details'] is Map) ? body['details'] : body;
|
||||
final serverBreakId = det['breakid'];
|
||||
final serverBreakId = res.map['breaklogid'] ?? res.map['breakid'];
|
||||
|
||||
if (serverBreakId != null) {
|
||||
await prefs.setInt(
|
||||
@@ -842,7 +779,7 @@ class BackgroundCollectionLog {
|
||||
await prefs.setInt('break_start_epoch', now.millisecondsSinceEpoch);
|
||||
} else {
|
||||
debugPrint(
|
||||
'[AUTO_SHIFT_END] ⚠️ Failed to create break log: ${response.statusCode}',
|
||||
'[AUTO_SHIFT_END] ⚠️ Failed to create break log: ${res.status}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -854,27 +791,15 @@ class BackgroundCollectionLog {
|
||||
await prefs.setInt('onduty', 0);
|
||||
await prefs.setBool('online', false);
|
||||
|
||||
// 6. Update Rider Log (Set Duty = 0)
|
||||
// We should also update the main rider log to say onduty=0
|
||||
final updateUrl = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.updateRiderLogLive
|
||||
: ApiConstants.updateRiderLogDev;
|
||||
|
||||
final updatePayload = {
|
||||
"userid": userid,
|
||||
"onduty": 0,
|
||||
"latitude": lat,
|
||||
"longitude": lng,
|
||||
};
|
||||
// 6. End duty, on v1.
|
||||
//
|
||||
// Was an unauthenticated POST of `{userid, onduty: 0}` to the
|
||||
// legacy `updateriderlog`. `PUT /miler/duty/end` closes whichever
|
||||
// duty log is open for the token's rider, which is the same fact
|
||||
// without the id or the open door.
|
||||
|
||||
try {
|
||||
await _httpClient
|
||||
.post(
|
||||
Uri.parse(updateUrl),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(updatePayload),
|
||||
)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
await MilerApi.endDuty();
|
||||
debugPrint('[AUTO_SHIFT_END] ✅ Rider status updated to Offline');
|
||||
} catch (_) {}
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
import 'dart:math' as math;
|
||||
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/background/backgroundservice.dart';
|
||||
import 'package:geolocator/geolocator.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/mqtt_service.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
|
||||
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 iso = _formatDateTimeFull(now);
|
||||
@@ -226,9 +226,6 @@ class _BackgroundRiderLog {
|
||||
payload['lastname'] = lastName.trim();
|
||||
}
|
||||
|
||||
final base = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.createRiderLogLive
|
||||
: ApiConstants.createRiderLogDev;
|
||||
|
||||
final provider = CreateRiderLogProvider();
|
||||
final resp = await provider.createRiderLog(payload);
|
||||
@@ -304,7 +301,7 @@ class _BackgroundRiderLog {
|
||||
|
||||
// 5. Lane: Status
|
||||
mqttService.updateStatus(
|
||||
riderStatus == 'active' ? 'Active' : MqttConstants.statusOnline,
|
||||
riderStatus == 'On_Pickup' ? 'Active' : MqttConstants.statusOnline,
|
||||
);
|
||||
|
||||
// 6. Lane: Periodic Log (Comprehensive Snapshot)
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:get/get.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
|
||||
import 'package:miler/views/helpers/constants/apiconstants.dart';
|
||||
import 'package:miler/background/foreground_service.dart' as fg;
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:miler/data/accepted_store.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
import 'package:miler/views/helpers/constants/apiconstants.dart';
|
||||
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
|
||||
import 'dart:io';
|
||||
import 'package:minio/minio.dart';
|
||||
@@ -1203,12 +1202,6 @@ class PickupsController extends GetxController
|
||||
return "$y-$m-$d $hh:$mm:$ss";
|
||||
}
|
||||
|
||||
String _resolveUpdateUrl() {
|
||||
return ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.updatePickupLive
|
||||
: ApiConstants.updatePickupDev;
|
||||
}
|
||||
|
||||
// ---------------- SharedPrefs helpers ----------------
|
||||
Future<void> setNotificationSent(bool value) async {
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:miler/data/miler_api.dart';
|
||||
///
|
||||
/// ── 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
|
||||
/// 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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:miler/views/helpers/constants/apiconstants.dart';
|
||||
import 'package:miler/Models/riders/riders_models.dart';
|
||||
import 'package:miler/providers/Riderlog/riderlog_provider.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/mqtt_service.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:battery_plus/battery_plus.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
|
||||
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') ?? '';
|
||||
debugPrint(
|
||||
'[RIDERLOG][CREATE LOGIN NOW] Active pickup: $hasActivePickups -> status: $riderStatus',
|
||||
@@ -315,7 +315,7 @@ class RiderLogController extends GetxController
|
||||
|
||||
// 1. Lane: Status
|
||||
mqttService.updateStatus(
|
||||
riderStatus == 'active' ? 'Active' : MqttConstants.statusOnline,
|
||||
riderStatus == 'On_Pickup' ? 'Active' : MqttConstants.statusOnline,
|
||||
);
|
||||
|
||||
// 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
|
||||
final bool hasActivePickups =
|
||||
prefs.getBool('has_live_pickup') ?? false;
|
||||
final String riderStatus = hasActivePickups ? 'active' : 'idle';
|
||||
final String riderStatus = heartbeatStatus(hasActiveWork: hasActivePickups);
|
||||
|
||||
final Map<String, dynamic> payload = {
|
||||
...baseTemplate,
|
||||
@@ -520,10 +520,19 @@ class RiderLogController extends GetxController
|
||||
await prefs.setInt('onduty', on ? 1 : 0);
|
||||
if (on) {
|
||||
await createLoginNowV2();
|
||||
final int interval = prefs.getInt('logseconds') ?? 0;
|
||||
if (interval > 0) {
|
||||
startAutoCreateLoginLoop(seconds: interval);
|
||||
}
|
||||
// ── Going on duty always starts the heartbeat ──
|
||||
//
|
||||
// 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
|
||||
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')
|
||||
// ✅ CRITICAL: When there are active pickup, use 30 seconds (same as pickup logs)
|
||||
// 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();
|
||||
|
||||
// ✅ 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 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)
|
||||
// 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
|
||||
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;
|
||||
if (onduty != 1) {
|
||||
debugPrint('[RIDERLOG][AUTO LOOP] Not starting - onduty=$onduty');
|
||||
@@ -1049,9 +1096,6 @@ class RiderLogController extends GetxController
|
||||
final breakhoursDouble = duration.inSeconds / 3600.0;
|
||||
|
||||
// Debug logs for terminal visibility
|
||||
debugPrint(
|
||||
'[BREAK][UPDATE] URL: ${ApiConstants.mainRoute == 'live' ? ApiConstants.updateBreakRiderLogLive : ApiConstants.updateBreakRiderLogDev}',
|
||||
);
|
||||
debugPrint(
|
||||
'[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
|
||||
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
|
||||
String? username = prefs.getString('username');
|
||||
|
||||
@@ -13,7 +13,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
/// ── The flag is gone ──
|
||||
///
|
||||
/// 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 call and only one of them was ever exercised.
|
||||
///
|
||||
|
||||
@@ -46,10 +46,11 @@ class DeviceTelemetry {
|
||||
/// problem as a rider at 12% and riding.
|
||||
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;
|
||||
|
||||
/// `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
|
||||
/// it is worth a column of its own on the console: a disabled location
|
||||
@@ -86,9 +87,7 @@ class DeviceTelemetry {
|
||||
String? connection;
|
||||
try {
|
||||
final result = await Connectivity().checkConnectivity();
|
||||
connection = result.isNotEmpty
|
||||
? result.first.toString().split('.').last
|
||||
: 'none';
|
||||
connection = _connectionName(result);
|
||||
} catch (e) {
|
||||
debugPrint('[TELEMETRY] connectivity unavailable: $e');
|
||||
}
|
||||
@@ -104,6 +103,7 @@ class DeviceTelemetry {
|
||||
service = null;
|
||||
}
|
||||
}
|
||||
service = _locationServiceName(service);
|
||||
|
||||
return DeviceTelemetry(
|
||||
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.
|
||||
///
|
||||
/// Only what was actually read: an absent key is a field the console draws as
|
||||
|
||||
89
lib/data/heartbeat.dart
Normal 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';
|
||||
@@ -135,9 +135,32 @@ class MilerApi {
|
||||
/// Telemetry wants strings for values that are numbers everywhere else.
|
||||
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]) {
|
||||
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');
|
||||
return '${t.year}-${p(t.month)}-${p(t.day)} '
|
||||
'${p(t.hour)}:${p(t.minute)}:${p(t.second)}';
|
||||
|
||||
@@ -12,10 +12,9 @@ import 'dart:io';
|
||||
/// day's cash figures without a word.
|
||||
///
|
||||
/// 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
|
||||
/// present valid, publicly-trusted certificates — `api.doormile.com` and
|
||||
/// `queue.workolik.com` verify strictly under TLS 1.3 against Let's Encrypt.
|
||||
/// Nothing needed the bypass.
|
||||
/// and then ships. The live host was checked before removing it:
|
||||
/// `api.doormile.com` verifies strictly under TLS 1.3 against a publicly
|
||||
/// trusted certificate. Nothing needed the bypass.
|
||||
///
|
||||
/// ── What is left, and why the class is still here ──
|
||||
///
|
||||
|
||||
@@ -11,7 +11,7 @@ import 'package:miler/data/miler_api.dart';
|
||||
///
|
||||
/// A `_buildSslBypassClient()` that returned an `IOClient` whose
|
||||
/// `badCertificateCallback` returned `true` for every certificate, pinned a
|
||||
/// hardcoded IP for `queue.workolik.com`, and hand-rolled its own
|
||||
/// 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
|
||||
/// 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
|
||||
@@ -33,6 +33,132 @@ String _s(dynamic v) => v == null ? '' : v.toString();
|
||||
|
||||
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.
|
||||
Map<String, dynamic> _dutyEnvelope(Map data) {
|
||||
final bool on = data['onduty'] == true;
|
||||
@@ -54,11 +180,11 @@ Future<Map<String, dynamic>?> _startDuty(Map data) async {
|
||||
lon: _d(data['longitude']),
|
||||
);
|
||||
if (res.ok) {
|
||||
final id = int.tryParse('${res.map['dutylogid'] ?? 0}') ?? 0;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('logid', id);
|
||||
await prefs.setInt('logId', id);
|
||||
await prefs.setInt('dutylogid', id);
|
||||
// Looked at one key on one level and stored whatever it found, including
|
||||
// a zero — which shut the heartbeat's gate permanently on a response that
|
||||
// had merely spelled the id differently. See [_dutyLogIdIn].
|
||||
final id = _dutyLogIdIn(res);
|
||||
await _rememberDuty(id);
|
||||
return {
|
||||
'status': true,
|
||||
'code': 200,
|
||||
@@ -76,12 +202,19 @@ Future<Map<String, dynamic>?> _currentDuty() async {
|
||||
if (!res.ok) return {'status': false, 'code': res.status};
|
||||
|
||||
final on = res.map['onduty'] == true;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('onduty', on ? 1 : 0);
|
||||
if (on) {
|
||||
final id = int.tryParse('${res.map['dutylogid'] ?? 0}') ?? 0;
|
||||
await prefs.setInt('logid', id);
|
||||
await prefs.setInt('logId', id);
|
||||
// ── The line that was missing ──
|
||||
//
|
||||
// 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);
|
||||
}
|
||||
@@ -108,11 +241,28 @@ Future<Map<String, dynamic>?> _heartbeat(Map data) async {
|
||||
heading: _d(data['heading']),
|
||||
);
|
||||
|
||||
// Deliberately not awaited into the result: a telemetry failure must never
|
||||
// look like a location failure, because the caller treats the latter as duty
|
||||
// state going wrong.
|
||||
unawaited(
|
||||
MilerApi.postLog(
|
||||
// ── Awaited, but its outcome is still discarded ──
|
||||
//
|
||||
// This was `unawaited(...)`, for a good reason that turned out to be the
|
||||
// wrong mechanism: a telemetry failure must never read as a location failure,
|
||||
// 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,
|
||||
longitude: lon,
|
||||
speed: _d(data['speed']),
|
||||
@@ -137,8 +287,18 @@ Future<Map<String, dynamic>?> _heartbeat(Map data) async {
|
||||
? null
|
||||
: _s(data['location_service']),
|
||||
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
|
||||
? ApiConfig.okEnvelope()
|
||||
@@ -158,13 +318,28 @@ bool _isOffDutyIntent(Map data) {
|
||||
|
||||
class CreateRiderLogProvider {
|
||||
/// 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(
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final int existing = prefs.getInt('dutylogid') ?? 0;
|
||||
if (existing <= 0) return _startDuty(data);
|
||||
return _heartbeat(data);
|
||||
if (await _dutyEstablished()) return _heartbeat(data);
|
||||
|
||||
final started = await _startDuty(data);
|
||||
if (await _dutyEstablished()) await _heartbeat(data);
|
||||
return started;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,19 +348,25 @@ class UpdateRiderLogProvider {
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
if (_isOffDutyIntent(data)) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('dutylogid', 0);
|
||||
await _forgetDuty();
|
||||
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
|
||||
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
|
||||
: {'status': false, 'code': res.status};
|
||||
}
|
||||
|
||||
final onduty = data['onduty'];
|
||||
if (onduty == 1 || _s(onduty) == '1') {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if ((prefs.getInt('dutylogid') ?? 0) <= 0) return _startDuty(data);
|
||||
if ((onduty == 1 || _s(onduty) == '1') && !await _dutyEstablished()) {
|
||||
// Same shape as [CreateRiderLogProvider.createRiderLog]: establish duty,
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'dart:convert';
|
||||
import 'package:miler/Models/login/login.dart';
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/data/heartbeat.dart';
|
||||
import 'package:miler/data/mock_backend.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
@@ -93,7 +94,7 @@ class AuthProvider {
|
||||
int? pin,
|
||||
String? pinRaw,
|
||||
}) 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.
|
||||
return _loginNew(
|
||||
contactNo: contactNo,
|
||||
@@ -303,6 +304,15 @@ class AuthProvider {
|
||||
'logid': 0,
|
||||
'partnerid': 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 ──
|
||||
//
|
||||
// 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
|
||||
Future<Login> loginParsed({
|
||||
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('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('tenantid', details['tenantid'] ?? 0);
|
||||
|
||||
@@ -15,7 +15,7 @@ import 'package:miler/data/accepted_store.dart';
|
||||
/// ── What changed ──
|
||||
///
|
||||
/// This file used to carry two complete implementations of every call: a
|
||||
/// legacy one that spoke to `queue.workolik.com` through a hand-built
|
||||
/// legacy one that spoke to the retired backend through a hand-built
|
||||
/// `IOClient` — one that disabled certificate checking, pinned a hardcoded IP
|
||||
/// and did its own TLS upgrade to work around a carrier's broken DNS — and a
|
||||
/// v1 one behind `if (ApiConfig.useNewApi)`. The legacy backend is gone, and
|
||||
|
||||
@@ -2,12 +2,9 @@ import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:miler/Models/summary/pickupstats.dart';
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/views/helpers/constants/apiconstants.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class SummaryProvider {
|
||||
final String baseUrl = ApiConstants.summaryApiLive;
|
||||
|
||||
Future<PickupStats?> fetchSummaryStats(int userId) => _fetchSummaryStatsNew();
|
||||
|
||||
/// `GET /miler/earnings?period=daily|weekly|monthly`
|
||||
|
||||
@@ -17,7 +17,6 @@ import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:miler/providers/pickup/pickup_provider.dart';
|
||||
import 'package:miler/providers/pickuplog/pickuplog_provider.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/miler_surface.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/data/accepted_store.dart';
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/data/heartbeat.dart';
|
||||
import 'package:miler/data/consignment_state.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/collect_payment.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
@@ -1699,11 +1699,13 @@ class _HomepageState extends State<Homepage>
|
||||
Future<Duration> _getLogInterval() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final secs = prefs.getInt('logseconds');
|
||||
final int interval = (secs != null && secs > 0) ? secs : 30;
|
||||
// Already correct — routed through the shared resolver so the 30 lives in
|
||||
// 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);
|
||||
} catch (_) {
|
||||
return const Duration(seconds: 30);
|
||||
return const Duration(seconds: kDefaultLogSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -932,7 +932,15 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
|
||||
final top = media.padding.top + 72;
|
||||
await _camera.fit(
|
||||
<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
|
||||
/// view. A drag with many arbitrary rest points feels loose and gives the
|
||||
/// 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 _sheetWorking = 0.45;
|
||||
static const double _sheetDetail = 0.75;
|
||||
|
||||
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(
|
||||
initialChildSize: _sheetWorking,
|
||||
minChildSize: _sheetGlance,
|
||||
maxChildSize: _sheetDetail,
|
||||
initialChildSize: working,
|
||||
minChildSize: glance,
|
||||
maxChildSize: detail,
|
||||
// Snap to the three, so the sheet always settles somewhere meant.
|
||||
snap: true,
|
||||
snapSizes: const [_sheetGlance, _sheetWorking, _sheetDetail],
|
||||
snapSizes: [glance, working, detail],
|
||||
// ── Frosted, not opaque ──
|
||||
//
|
||||
// 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.
|
||||
child: ListView(
|
||||
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: [
|
||||
_LiveEtaHeader(
|
||||
meters: _liveMetersToStop,
|
||||
@@ -1635,13 +1699,11 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
|
||||
if (_isNavigating || !mounted) return;
|
||||
final outcome = await DeliveryProofPage.askOutcome(context);
|
||||
if (!mounted || outcome == null) return;
|
||||
await _closeDelivery(
|
||||
switch (outcome) {
|
||||
ProofOutcome.delivered => DeliveryOutcome.delivered,
|
||||
ProofOutcome.skipped => DeliveryOutcome.skipped,
|
||||
ProofOutcome.cancelled => DeliveryOutcome.cancelled,
|
||||
},
|
||||
);
|
||||
await _closeDelivery(switch (outcome) {
|
||||
ProofOutcome.delivered => DeliveryOutcome.delivered,
|
||||
ProofOutcome.skipped => DeliveryOutcome.skipped,
|
||||
ProofOutcome.cancelled => DeliveryOutcome.cancelled,
|
||||
});
|
||||
}
|
||||
|
||||
/// The end of the stop, whichever of the three it is.
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_tts/flutter_tts.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:audioplayers/audioplayers.dart';
|
||||
import 'dart:math' as math;
|
||||
@@ -20,7 +21,6 @@ class BackgroundDeliveryLog {
|
||||
|
||||
static final CreateDeliveryLogProvider _logProvider =
|
||||
CreateDeliveryLogProvider();
|
||||
static final http.Client _httpClient = http.Client();
|
||||
static final FlutterTts _tts = FlutterTts();
|
||||
static final AudioPlayer _proximityPlayer = AudioPlayer();
|
||||
|
||||
@@ -60,38 +60,37 @@ class BackgroundDeliveryLog {
|
||||
final now = DateTime.now();
|
||||
final today = '${now.year}-${_pad(now.month)}-${_pad(now.day)}';
|
||||
|
||||
// Build API URL using v1 endpoint (getdeliveries)
|
||||
final bool isLive = ApiConstants.mainRoute == 'live';
|
||||
final baseUrl = isLive
|
||||
? ApiConstants.currentDeliveryLive
|
||||
: ApiConstants.currentDeliveryDev;
|
||||
|
||||
final uri = Uri.parse(baseUrl).replace(
|
||||
queryParameters: {
|
||||
'userid': userId.toString(),
|
||||
'fromdate': today,
|
||||
'todate': today,
|
||||
't': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Fetching deliveries: $uri');
|
||||
|
||||
// Fetch deliveries from API using v1 endpoint
|
||||
final deliveries = await _fetchDeliveriesFromApi(uri);
|
||||
// ── The delivery twin of the pickup fetch, with the same two faults ──
|
||||
//
|
||||
// It built a `deliveries/getdeliveries` URL by hand and fetched it with a
|
||||
// bare `_httpClient.get` — **no bearer token** — and put `userid` in the
|
||||
// query string. The URL itself already resolved to a v1 route through the
|
||||
// [DeliveryApi] facade, so the call could only ever be answered 401.
|
||||
//
|
||||
// And the filter looked for `orderstatus == 'active'`, which is not a
|
||||
// value this app's vocabulary contains: [ApiConfig.legacyStatusFromNew]
|
||||
// maps the v1 statuses to `assigned` / `accepted` / `arrived` / `picked`,
|
||||
// never to `active`. So it returned nothing on every tick and fell
|
||||
// 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.
|
||||
final res = await MilerApi.bookings(date: today);
|
||||
final deliveries = res.list;
|
||||
|
||||
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((
|
||||
order,
|
||||
) {
|
||||
final status = (order['orderstatus']?.toString().toLowerCase() ?? '')
|
||||
.trim();
|
||||
final isActive = status == 'active';
|
||||
return isActive;
|
||||
final status = ApiConfig.legacyStatusFromNew(
|
||||
(order['status'] ?? order['orderstatus'])?.toString(),
|
||||
);
|
||||
return inProgress.contains(status);
|
||||
}).toList();
|
||||
|
||||
if (activeOrders.isEmpty) {
|
||||
@@ -202,11 +201,8 @@ class BackgroundDeliveryLog {
|
||||
'logstatus': await _resolveLogStatus(orderId, coords),
|
||||
};
|
||||
|
||||
// Determine API endpoint
|
||||
final url = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.createDeliveryLogLive
|
||||
: ApiConstants.createDeliveryLogDev;
|
||||
|
||||
// v1: `/miler/consignments/logs`, via the DeliveryApi facade.
|
||||
final url = ApiConstants.createDeliveryLogLive;
|
||||
// Post the log
|
||||
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Posting log for $orderId');
|
||||
|
||||
@@ -434,33 +430,6 @@ class BackgroundDeliveryLog {
|
||||
}
|
||||
|
||||
/// 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
|
||||
static String _pad(int n) => n.toString().padLeft(2, '0');
|
||||
@@ -540,10 +509,6 @@ class BackgroundDeliveryLog {
|
||||
final List<String> remaining = [];
|
||||
bool anySuccess = false;
|
||||
|
||||
final url = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.createDeliveryLogLive
|
||||
: ApiConstants.createDeliveryLogDev;
|
||||
|
||||
for (final itemStr in queue) {
|
||||
try {
|
||||
final Map<String, dynamic> item = jsonDecode(itemStr);
|
||||
@@ -554,6 +519,8 @@ class BackgroundDeliveryLog {
|
||||
|
||||
if (payload.isEmpty) continue;
|
||||
|
||||
// v1: `/miler/consignments/logs`, via the DeliveryApi facade.
|
||||
final url = ApiConstants.createDeliveryLogLive;
|
||||
debugPrint(
|
||||
'[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Retrying for orderId: $orderId',
|
||||
);
|
||||
@@ -770,14 +737,18 @@ class BackgroundDeliveryLog {
|
||||
'[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 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) {
|
||||
debugPrint(
|
||||
'[AUTO_SHIFT_END] ❌ Missing userid, cannot create break log',
|
||||
@@ -785,61 +756,28 @@ class BackgroundDeliveryLog {
|
||||
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 int localBreakId =
|
||||
(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 =
|
||||
'${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}';
|
||||
|
||||
final payload = <String, dynamic>{
|
||||
"breakid": localBreakId,
|
||||
"logid": logid,
|
||||
"breakdate": breakdate,
|
||||
"userid": userid,
|
||||
"partnerid": partnerid,
|
||||
"shiftid": shiftid,
|
||||
"breakstart": breakstart,
|
||||
"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.
|
||||
// 4. Close the shift with a break, on v1.
|
||||
//
|
||||
// This hand-posted the retired backend's `partners/createbreaklog`
|
||||
// with a bare `_httpClient.post` and no bearer
|
||||
// token — an unauthenticated write keyed on a `userid` in the
|
||||
// body. `POST /miler/breaks/start` is the v1 endpoint, and
|
||||
// [MilerApi] attaches the session.
|
||||
debugPrint('[AUTO_SHIFT_END] Creating break log (v1)');
|
||||
|
||||
try {
|
||||
final response = await _httpClient
|
||||
.post(
|
||||
Uri.parse(url),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(payload),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
final res = await MilerApi.startBreak('Shift_End');
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
if (res.ok) {
|
||||
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 body = jsonDecode(response.body);
|
||||
final det = (body['details'] is Map) ? body['details'] : body;
|
||||
final serverBreakId = det['breakid'];
|
||||
final serverBreakId = res.map['breaklogid'] ?? res.map['breakid'];
|
||||
|
||||
if (serverBreakId != null) {
|
||||
await prefs.setInt(
|
||||
@@ -851,7 +789,7 @@ class BackgroundDeliveryLog {
|
||||
await prefs.setInt('break_start_epoch', now.millisecondsSinceEpoch);
|
||||
} else {
|
||||
debugPrint(
|
||||
'[AUTO_SHIFT_END] ⚠️ Failed to create break log: ${response.statusCode}',
|
||||
'[AUTO_SHIFT_END] ⚠️ Failed to create break log: ${res.status}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -863,27 +801,15 @@ class BackgroundDeliveryLog {
|
||||
await prefs.setInt('onduty', 0);
|
||||
await prefs.setBool('online', false);
|
||||
|
||||
// 6. Update Rider Log (Set Duty = 0)
|
||||
// We should also update the main rider log to say onduty=0
|
||||
final updateUrl = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.updateRiderLogLive
|
||||
: ApiConstants.updateRiderLogDev;
|
||||
|
||||
final updatePayload = {
|
||||
"userid": userid,
|
||||
"onduty": 0,
|
||||
"latitude": lat,
|
||||
"longitude": lng,
|
||||
};
|
||||
// 6. End duty, on v1.
|
||||
//
|
||||
// Was an unauthenticated POST of `{userid, onduty: 0}` to the
|
||||
// legacy `updateriderlog`. `PUT /miler/duty/end` closes whichever
|
||||
// duty log is open for the token's rider, which is the same fact
|
||||
// without the id or the open door.
|
||||
|
||||
try {
|
||||
await _httpClient
|
||||
.post(
|
||||
Uri.parse(updateUrl),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(updatePayload),
|
||||
)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
await MilerApi.endDuty();
|
||||
debugPrint('[AUTO_SHIFT_END] ✅ Rider status updated to Offline');
|
||||
} catch (_) {}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
import 'dart:math' as math;
|
||||
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/background/backgroundservice.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
@@ -354,12 +353,9 @@ class _BackgroundRiderLog {
|
||||
payload['lastname'] = lastName.trim();
|
||||
}
|
||||
|
||||
final base = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.createRiderLogLive
|
||||
: ApiConstants.createRiderLogDev;
|
||||
|
||||
final provider = CreateRiderLogProvider();
|
||||
final resp = await provider.createRiderLog(base, payload);
|
||||
final resp = await provider.createRiderLog(payload);
|
||||
|
||||
if (resp == null || resp.isEmpty) return;
|
||||
final det = (resp['details'] is Map<String, dynamic>)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
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/providers/Riderlog/riderlog_provider.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/utils/kalman_filter.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:battery_plus/battery_plus.dart';
|
||||
|
||||
@@ -40,11 +40,9 @@ class RiderLogController extends GetxController {
|
||||
Future<void> fetchRiderLogs({required String userId}) async {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
final base = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.getRiderLogLive
|
||||
: ApiConstants.getRiderLogDev;
|
||||
final url = "$base?userid=$userId";
|
||||
final response = await _getProvider.getRiderLog(url);
|
||||
// v1 answers duty for the *token's* rider — `userid` is neither sent
|
||||
// nor needed. See [GetRiderLogProvider].
|
||||
final response = await _getProvider.getRiderLog();
|
||||
|
||||
// Handle either {details: {...}} or {data: [...]} shapes
|
||||
if (response == null) {
|
||||
@@ -122,11 +120,7 @@ class RiderLogController extends GetxController {
|
||||
}
|
||||
|
||||
Future<void> fetchRiderCount({required String userId}) async {
|
||||
final base = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.getRiderCountLive
|
||||
: ApiConstants.getRiderCountDev;
|
||||
final url = "$base?userid=$userId";
|
||||
final response = await _getProvider.getRiderCount(url);
|
||||
final response = await _getProvider.getRiderCount();
|
||||
final dynamic cnt = response != null ? response['count'] : null;
|
||||
riderCount.value = cnt is num
|
||||
? cnt.toInt()
|
||||
@@ -134,46 +128,30 @@ class RiderLogController extends GetxController {
|
||||
}
|
||||
|
||||
Future<bool> createLogin(RiderLogin login) async {
|
||||
final base = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.createRiderLogLive
|
||||
: ApiConstants.createRiderLogDev;
|
||||
final ok = await _createProvider.createRiderLog(
|
||||
base,
|
||||
_CreateRiderLogRequestCompat.fromRiderLogin(login),
|
||||
);
|
||||
return ok != null && ok.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<bool> updateLog(RiderUpdate update) async {
|
||||
final base = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.updateRiderLogLive
|
||||
: ApiConstants.updateRiderLogDev;
|
||||
final ok = await _updateProvider.updateRiderLog(
|
||||
base,
|
||||
_UpdateRiderLogRequestCompat.fromRiderUpdate(update),
|
||||
);
|
||||
return ok != null && ok.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<bool> createBreak(RiderBreak brk) async {
|
||||
final base = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.createBreakRiderLogLive
|
||||
: ApiConstants.createBreakRiderLogDev;
|
||||
final ok = await _breakProvider.createBreakRiderLog(
|
||||
base,
|
||||
_BreakLogRequestCompat.fromRiderBreak(brk),
|
||||
);
|
||||
return ok != null && ok.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<bool> updateBreak(RiderBreak brk) async {
|
||||
final base = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.updateBreakRiderLogLive
|
||||
: ApiConstants.updateBreakRiderLogDev;
|
||||
final ok = await _breakProvider.updateBreakRiderLog(
|
||||
base,
|
||||
_BreakLogRequestCompat.fromRiderBreak(brk),
|
||||
);
|
||||
// `PUT /miler/breaks/end` takes no body and closes whichever break is
|
||||
// open, which the server knows and the app was only guessing at.
|
||||
final ok = await _breakProvider.updateBreakRiderLog();
|
||||
return ok != null && ok.isNotEmpty;
|
||||
}
|
||||
|
||||
@@ -190,10 +168,6 @@ class RiderLogController extends GetxController {
|
||||
required String latitude,
|
||||
required String longitude,
|
||||
}) async {
|
||||
final url = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.updateBreakRiderLogLive
|
||||
: ApiConstants.updateBreakRiderLogDev;
|
||||
|
||||
final payload = {
|
||||
"breakid": breakid,
|
||||
"logid": logid,
|
||||
@@ -209,11 +183,9 @@ class RiderLogController extends GetxController {
|
||||
};
|
||||
|
||||
try {
|
||||
// Debug: show exact URL and JSON body
|
||||
debugPrint('[BREAK][UPDATE] URL: $url');
|
||||
debugPrint('[BREAK][UPDATE] Body: $payload');
|
||||
|
||||
final response = await _breakProvider.updateBreakRiderLog(url, payload);
|
||||
final response = await _breakProvider.updateBreakRiderLog();
|
||||
|
||||
// Debug: show raw response
|
||||
debugPrint(
|
||||
@@ -375,13 +347,9 @@ class RiderLogController extends GetxController {
|
||||
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');
|
||||
|
||||
final resp = await _createProvider.createRiderLog(base, payload);
|
||||
final resp = await _createProvider.createRiderLog(payload);
|
||||
debugPrint(
|
||||
'[RIDERLOG][CREATE LOGIN NOW] Response: ${resp == null ? 'null' : resp.toString()}',
|
||||
);
|
||||
@@ -391,7 +359,7 @@ class RiderLogController extends GetxController {
|
||||
debugPrint(
|
||||
'[RIDERLOG][CREATE LOGIN NOW] Failed, saving to offline queue',
|
||||
);
|
||||
await _saveToOfflineQueue(base, payload);
|
||||
await _saveToOfflineQueue('/miler/logs', payload);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -500,11 +468,7 @@ class RiderLogController extends GetxController {
|
||||
payload.remove('lastname');
|
||||
}
|
||||
|
||||
final base = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.createRiderLogLive
|
||||
: ApiConstants.createRiderLogDev;
|
||||
|
||||
await _saveToOfflineQueue(base, payload);
|
||||
await _saveToOfflineQueue('/miler/logs', payload);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[RIDERLOG] Error saving offline log: $e');
|
||||
@@ -532,10 +496,15 @@ class RiderLogController extends GetxController {
|
||||
await prefs.setInt('onduty', on ? 1 : 0);
|
||||
if (on) {
|
||||
await createLoginNowV2();
|
||||
final int interval = prefs.getInt('logseconds') ?? 0;
|
||||
if (interval > 0) {
|
||||
startAutoCreateLoginLoop(seconds: interval);
|
||||
}
|
||||
// ── Going on duty always starts the heartbeat ──
|
||||
//
|
||||
// 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
|
||||
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')
|
||||
// ✅ CRITICAL: When there are active deliveries, use 30 seconds (same as delivery logs)
|
||||
// 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();
|
||||
|
||||
// ✅ Check if there are active deliveries - if yes, use 30 seconds (same as delivery logs)
|
||||
final bool hasActiveDeliveries =
|
||||
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)
|
||||
// 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
|
||||
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;
|
||||
if (onduty != 1) {
|
||||
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 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
|
||||
final payload = <String, dynamic>{
|
||||
"breakid": localBreakId,
|
||||
@@ -941,13 +939,12 @@ class RiderLogController extends GetxController {
|
||||
};
|
||||
|
||||
// Debug logs for terminal visibility
|
||||
debugPrint('[BREAK][CREATE] URL: $url');
|
||||
debugPrint('[BREAK][CREATE] Payload: $payload');
|
||||
|
||||
// Persist local break id immediately to ensure update can reference it
|
||||
await prefs.setInt('breakId', localBreakId);
|
||||
|
||||
final resp = await _breakProvider.createBreakRiderLog(url, payload);
|
||||
final resp = await _breakProvider.createBreakRiderLog(payload);
|
||||
|
||||
debugPrint(
|
||||
'[BREAK][CREATE] Response: ${resp == null ? 'null' : resp.toString()}',
|
||||
@@ -1045,9 +1042,6 @@ class RiderLogController extends GetxController {
|
||||
final breakhoursDouble = duration.inSeconds / 3600.0;
|
||||
|
||||
// Debug logs for terminal visibility
|
||||
debugPrint(
|
||||
'[BREAK][UPDATE] URL: ${ApiConstants.mainRoute == 'live' ? ApiConstants.updateBreakRiderLogLive : ApiConstants.updateBreakRiderLogDev}',
|
||||
);
|
||||
debugPrint(
|
||||
'[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 loginTime = _formatTime(now);
|
||||
|
||||
final url = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.createRiderLogLive
|
||||
: ApiConstants.createRiderLogDev;
|
||||
|
||||
// ✅ Check if there are active deliveries to set status
|
||||
final bool hasActiveDeliveries =
|
||||
prefs.getBool('has_live_deliveries') ?? false;
|
||||
@@ -1141,10 +1131,9 @@ class RiderLogController extends GetxController {
|
||||
userfcmtoken: userfcmtoken ?? '',
|
||||
).toJson();
|
||||
|
||||
debugPrint('[RIDERLOG][CREATE LOGIN] URL: $url');
|
||||
debugPrint('[RIDERLOG][CREATE LOGIN] Payload: $payload');
|
||||
|
||||
final resp = await _createProvider.createRiderLog(url, payload);
|
||||
final resp = await _createProvider.createRiderLog(payload);
|
||||
debugPrint(
|
||||
'[RIDERLOG][CREATE LOGIN] Response: ${resp == null ? 'null' : resp.toString()}',
|
||||
);
|
||||
@@ -1205,7 +1194,7 @@ class RiderLogController extends GetxController {
|
||||
// We can use _createProvider generic call
|
||||
|
||||
debugPrint('[RIDERLOG][OFFLINE] Retrying: $payload');
|
||||
final resp = await _createProvider.createRiderLog(url, payload);
|
||||
final resp = await _createProvider.createRiderLog(payload);
|
||||
|
||||
if (resp != null && resp.isNotEmpty) {
|
||||
debugPrint('[RIDERLOG][OFFLINE] Success!');
|
||||
|
||||
@@ -107,7 +107,7 @@ class DeliveryApi {
|
||||
/// **ABSENT** — the generic status write the ported flow uses for every
|
||||
/// 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
|
||||
/// 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.
|
||||
@@ -117,7 +117,7 @@ class DeliveryApi {
|
||||
|
||||
/// **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');
|
||||
|
||||
/// **LIVE** — read them back.
|
||||
@@ -142,7 +142,7 @@ class DeliveryApi {
|
||||
|
||||
/// **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
|
||||
/// delivery flow does not always hold.
|
||||
static String get createDeliveryLog => url('/miler/consignments/logs');
|
||||
|
||||
@@ -1,260 +1,46 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
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.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE DELIVERY LINE'S RIDER LOG IS THE PARCEL LINE'S RIDER LOG
|
||||
///
|
||||
/// **Empty since the delivery line moved to Doormile.** The one entry here
|
||||
/// 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.
|
||||
/// ── What this file used to be ──
|
||||
///
|
||||
/// It is deliberately not re-pointed at the Doormile host: that IP belongs to
|
||||
/// the old backend, and pinning it for `api.doormile.com` would send every
|
||||
/// request to the wrong server with the right SNI. If Doormile ever needs the
|
||||
/// same workaround, add it here with its own verified address.
|
||||
const _knownGoodIPs = <String, String>{};
|
||||
/// A second, complete implementation of duty, breaks and the location
|
||||
/// heartbeat, posting to the two retired pre-v1 hosts over raw `http`. It survived the migration only because
|
||||
/// nothing in `lib/` imported it: the delivery shell reaches it through
|
||||
/// [riderShell], which is production for any tenant on the delivery line.
|
||||
///
|
||||
/// 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:
|
||||
/// 1. Bypasses SSL certificate errors
|
||||
/// 2. Forces known-good IPs for hosts where carrier DNS returns broken CDN nodes
|
||||
/// 3. Manually does TLS upgrade with correct SNI (hostname, not IP)
|
||||
IOClient _buildSslBypassClient() {
|
||||
final httpClient = HttpClient()
|
||||
..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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
export 'package:miler/providers/Riderlog/riderlog_provider.dart'
|
||||
show
|
||||
BreakRiderLogProvider,
|
||||
CreateRiderLogProvider,
|
||||
GetRiderLogProvider,
|
||||
UpdateRiderLogProvider,
|
||||
unawaited;
|
||||
|
||||
@@ -1,67 +1,25 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:http/io_client.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
// Combined Deliverylog providers:
|
||||
|
||||
/// Hardcoded known-good IPs for hosts where carrier DNS returns broken CDN nodes.
|
||||
/// 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
|
||||
/// matching map in `providers/Riderlog/riderlog_provider.dart`. The old entry
|
||||
/// pinned `queue.workolik.com`, a host nothing calls now, and must not be
|
||||
/// re-pointed at `api.doormile.com`.
|
||||
const _knownGoodIPs = <String, String>{};
|
||||
|
||||
/// Creates an IOClient that:
|
||||
/// 1. Bypasses SSL certificate errors
|
||||
/// 2. Forces known-good IPs to avoid broken CDN nodes from carrier DNS
|
||||
/// 3. Manually does TLS upgrade with correct SNI (hostname, not IP)
|
||||
IOClient _buildSslBypassClient() {
|
||||
final httpClient = HttpClient()
|
||||
..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);
|
||||
}
|
||||
/// ── The SSL bypass is gone ──
|
||||
///
|
||||
/// This file built its own `IOClient` with `onBadCertificate: (_) => true` and
|
||||
/// a hand-rolled `SecureSocket` upgrade, so **every certificate was accepted**
|
||||
/// — a delivery rider's position, his order ids and his session were readable
|
||||
/// and rewritable by anything on the path. The parcel line deleted the same
|
||||
/// code (see `providers/Riderlog/riderlog_provider.dart`); the delivery line
|
||||
/// kept it because nothing in `lib/` imported this file directly.
|
||||
///
|
||||
/// It also sent **no `Authorization` header**. The URLs it posts to are v1
|
||||
/// routes on `api.doormile.com` — resolved through the [DeliveryApi] facade —
|
||||
/// so every one of these calls could only ever have been answered with a 401.
|
||||
/// A plain client plus [ApiConfig.authHeaders] fixes both at once.
|
||||
Future<Map<String, String>> _headers() => ApiConfig.authHeaders();
|
||||
|
||||
class CreateDeliveryLogProvider {
|
||||
Future<Map<String, dynamic>?> createDeliveryLog(
|
||||
@@ -70,19 +28,12 @@ class CreateDeliveryLogProvider {
|
||||
bool wrapInArray = true,
|
||||
}) async {
|
||||
Map<String, dynamic>? result;
|
||||
final client = _buildSslBypassClient();
|
||||
final client = http.Client();
|
||||
try {
|
||||
final url = Uri.parse(urldata);
|
||||
final body = json.encode(wrapInArray ? [data] : data);
|
||||
final response = await client
|
||||
.post(
|
||||
url,
|
||||
body: body,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
)
|
||||
.post(url, body: body, headers: await _headers())
|
||||
.timeout(const Duration(seconds: 10));
|
||||
debugPrint('createDeliveryLog url $urldata');
|
||||
debugPrint(body);
|
||||
@@ -110,18 +61,11 @@ class UpdateDeliveryProvider {
|
||||
String urldata,
|
||||
) async {
|
||||
Map<String, dynamic>? updateDeliveryResponse;
|
||||
final client = _buildSslBypassClient();
|
||||
final client = http.Client();
|
||||
try {
|
||||
final url = Uri.parse(urldata);
|
||||
final response = await client
|
||||
.put(
|
||||
url,
|
||||
body: json.encode(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
)
|
||||
.put(url, body: json.encode(data), headers: await _headers())
|
||||
.timeout(const Duration(seconds: 10));
|
||||
debugPrint('updateDelivery url $urldata');
|
||||
debugPrint('updateDelivery status ${response.statusCode}');
|
||||
@@ -148,18 +92,11 @@ class UpdateDeliveryProvider {
|
||||
String urldata,
|
||||
) async {
|
||||
Map<String, dynamic>? updateDeliveryResponse;
|
||||
final client = _buildSslBypassClient();
|
||||
final client = http.Client();
|
||||
try {
|
||||
final url = Uri.parse(urldata);
|
||||
final response = await client
|
||||
.put(
|
||||
url,
|
||||
body: json.encode(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
)
|
||||
.put(url, body: json.encode(data), headers: await _headers())
|
||||
.timeout(const Duration(seconds: 10));
|
||||
debugPrint('updateArrived url $urldata');
|
||||
debugPrint('updateArrived status ${response.statusCode}');
|
||||
@@ -185,18 +122,11 @@ class UpdateDeliveryProvider {
|
||||
String urldata,
|
||||
) async {
|
||||
Map<String, dynamic>? updateDeliveryResponse;
|
||||
final client = _buildSslBypassClient();
|
||||
final client = http.Client();
|
||||
try {
|
||||
final url = Uri.parse(urldata);
|
||||
final response = await client
|
||||
.put(
|
||||
url,
|
||||
body: json.encode(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
)
|
||||
.put(url, body: json.encode(data), headers: await _headers())
|
||||
.timeout(const Duration(seconds: 10));
|
||||
debugPrint('updatePicked url $urldata');
|
||||
debugPrint('updatePicked status ${response.statusCode}');
|
||||
@@ -222,18 +152,11 @@ class UpdateDeliveryProvider {
|
||||
String urldata,
|
||||
) async {
|
||||
Map<String, dynamic>? updateDeliveryResponse;
|
||||
final client = _buildSslBypassClient();
|
||||
final client = http.Client();
|
||||
try {
|
||||
final url = Uri.parse(urldata);
|
||||
final response = await client
|
||||
.put(
|
||||
url,
|
||||
body: json.encode(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
)
|
||||
.put(url, body: json.encode(data), headers: await _headers())
|
||||
.timeout(const Duration(seconds: 10));
|
||||
debugPrint('updateActive url $urldata');
|
||||
debugPrint('updateActive status ${response.statusCode}');
|
||||
@@ -258,7 +181,7 @@ class UpdateDeliveryProvider {
|
||||
class GetDeliveryLogProvider {
|
||||
Future<Map<String, dynamic>?> getDeliveryLog(String urldata) async {
|
||||
Map<String, dynamic>? result;
|
||||
final client = _buildSslBypassClient();
|
||||
final client = http.Client();
|
||||
try {
|
||||
final url = Uri.parse(urldata);
|
||||
final response = await client.get(
|
||||
|
||||
@@ -34,6 +34,7 @@ import 'package:miler/xpress/views/Dashboard/deliveries/deliveries.dart'
|
||||
as deliveries;
|
||||
import 'package:miler/xpress/background/live_tracking_service.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
|
||||
/// 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 {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final secs = prefs.getInt('logseconds');
|
||||
final int interval = (secs != null && secs > 0) ? secs : 30;
|
||||
// Already correct — routed through the shared resolver so the 30 lives in
|
||||
// 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);
|
||||
} catch (_) {
|
||||
return const Duration(seconds: 30);
|
||||
return const Duration(seconds: kDefaultLogSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
353
test/duty_heartbeat_gate_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
291
test/heartbeat_interval_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
251
test/miler_log_contract_test.dart
Normal 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));
|
||||
});
|
||||
});
|
||||
}
|
||||