diff --git a/.gitignore b/.gitignore index 752c85f..9bade7d 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/ABOUT_MILER.md b/ABOUT_MILER.md index 3173feb..20dbfce 100644 --- a/ABOUT_MILER.md +++ b/ABOUT_MILER.md @@ -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 · diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4c4d088..5d8bfec 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -33,7 +33,7 @@ diff --git a/android/app/src/main/kotlin/com/nearle/gear/ShiftEndReceiver.kt b/android/app/src/main/kotlin/com/nearle/gear/ShiftEndReceiver.kt index fe8a42d..dfd773f 100644 --- a/android/app/src/main/kotlin/com/nearle/gear/ShiftEndReceiver.kt +++ b/android/app/src/main/kotlin/com/nearle/gear/ShiftEndReceiver.kt @@ -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" + } + +} diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..bc9c4f3 --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/android/key.properties b/android/key.properties index ad921fe..8a16a8a 100644 --- a/android/key.properties +++ b/android/key.properties @@ -1,4 +1,4 @@ -storePassword=123456789 -keyPassword=123456789 -keyAlias= doormile -storeFile=doormilerider-keystore.jks \ No newline at end of file +storePassword=W8UzA5NVaDTvix79rekmn8834m81tcKX +keyPassword=W8UzA5NVaDTvix79rekmn8834m81tcKX +keyAlias=doormile +storeFile=doormilerider-keystore.jks diff --git a/assets/images/ChatGPT Image Aug 24, 2026, 11_00_26 AM.png b/assets/images/ChatGPT Image Aug 24, 2026, 11_00_26 AM.png deleted file mode 100644 index 926a3b4..0000000 Binary files a/assets/images/ChatGPT Image Aug 24, 2026, 11_00_26 AM.png and /dev/null differ diff --git a/assets/images/_icon_preview.png b/assets/images/_icon_preview.png deleted file mode 100644 index ac86c5a..0000000 Binary files a/assets/images/_icon_preview.png and /dev/null differ diff --git a/assets/images/in.png b/assets/images/in.png deleted file mode 100644 index 29bc687..0000000 Binary files a/assets/images/in.png and /dev/null differ diff --git a/assets/images/nothing_finished_yet.png b/assets/images/nothing_finished_yet.png deleted file mode 100644 index af12cc2..0000000 Binary files a/assets/images/nothing_finished_yet.png and /dev/null differ diff --git a/assets/images/onlinebottom.png b/assets/images/onlinebottom.png deleted file mode 100644 index 7fb6511..0000000 Binary files a/assets/images/onlinebottom.png and /dev/null differ diff --git a/assets/images/orderssample.png b/assets/images/orderssample.png deleted file mode 100644 index 2d96571..0000000 Binary files a/assets/images/orderssample.png and /dev/null differ diff --git a/assets/images/pending.png b/assets/images/pending.png deleted file mode 100644 index 9d62a1d..0000000 Binary files a/assets/images/pending.png and /dev/null differ diff --git a/assets/images/preloader.png b/assets/images/preloader.png deleted file mode 100644 index 92cb60e..0000000 Binary files a/assets/images/preloader.png and /dev/null differ diff --git a/assets/images/selcart.png b/assets/images/selcart.png deleted file mode 100644 index eb96fcd..0000000 Binary files a/assets/images/selcart.png and /dev/null differ diff --git a/lib/background/backgroundservice.dart b/lib/background/backgroundservice.dart index 1630592..6e38764 100644 --- a/lib/background/backgroundservice.dart +++ b/lib/background/backgroundservice.dart @@ -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>().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 = { @@ -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> _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 - ? (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 remaining = []; bool anySuccess = false; - final url = ApiConstants.mainRoute == 'live' - ? ApiConstants.createPickupLogLive - : ApiConstants.createPickupLogDev; - for (final itemStr in queue) { try { final Map 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? 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 = { - "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 (_) {} diff --git a/lib/background/foreground_service.dart b/lib/background/foreground_service.dart index 3272c13..bfeea8a 100644 --- a/lib/background/foreground_service.dart +++ b/lib/background/foreground_service.dart @@ -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) diff --git a/lib/controllers/logcontroller.dart b/lib/controllers/logcontroller.dart index ef5f36b..bbc913f 100644 --- a/lib/controllers/logcontroller.dart +++ b/lib/controllers/logcontroller.dart @@ -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'; diff --git a/lib/controllers/pickups_controller.dart b/lib/controllers/pickups_controller.dart index bd5d641..06d8922 100644 --- a/lib/controllers/pickups_controller.dart +++ b/lib/controllers/pickups_controller.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 setNotificationSent(bool value) async { try { diff --git a/lib/controllers/rewards_controller.dart b/lib/controllers/rewards_controller.dart index 14c7222..ed491e1 100644 --- a/lib/controllers/rewards_controller.dart +++ b/lib/controllers/rewards_controller.dart @@ -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 diff --git a/lib/controllers/riderlog.dart b/lib/controllers/riderlog.dart index 25e7225..e2f74d4 100644 --- a/lib/controllers/riderlog.dart +++ b/lib/controllers/riderlog.dart @@ -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 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`, 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 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'); diff --git a/lib/data/api_config.dart b/lib/data/api_config.dart index 8a9979d..1119eb1 100644 --- a/lib/data/api_config.dart +++ b/lib/data/api_config.dart @@ -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. /// diff --git a/lib/data/device_telemetry.dart b/lib/data/device_telemetry.dart index 871d56c..b6cb4aa 100644 --- a/lib/data/device_telemetry.dart +++ b/lib/data/device_telemetry.dart @@ -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 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 diff --git a/lib/data/heartbeat.dart b/lib/data/heartbeat.dart new file mode 100644 index 0000000..fe144b3 --- /dev/null +++ b/lib/data/heartbeat.dart @@ -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'; diff --git a/lib/data/miler_api.dart b/lib/data/miler_api.dart index 7df8ce1..5d43151 100644 --- a/lib/data/miler_api.dart +++ b/lib/data/miler_api.dart @@ -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)}'; diff --git a/lib/helpers/http_overrides.dart b/lib/helpers/http_overrides.dart index 1c911c0..41f7c76 100644 --- a/lib/helpers/http_overrides.dart +++ b/lib/helpers/http_overrides.dart @@ -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 ── /// diff --git a/lib/providers/Riderlog/riderlog_provider.dart b/lib/providers/Riderlog/riderlog_provider.dart index 8b34c7c..0db1839 100644 --- a/lib/providers/Riderlog/riderlog_provider.dart +++ b/lib/providers/Riderlog/riderlog_provider.dart @@ -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 _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 = [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 _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 _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 _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 _dutyEnvelope(Map data) { final bool on = data['onduty'] == true; @@ -54,11 +180,11 @@ Future?> _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?> _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?> _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?> _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?> createRiderLog( Map 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 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); } diff --git a/lib/providers/auth/auth_provider.dart b/lib/providers/auth/auth_provider.dart index f9db37e..b5ef2be 100644 --- a/lib/providers/auth/auth_provider.dart +++ b/lib/providers/auth/auth_provider.dart @@ -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 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 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); diff --git a/lib/providers/pickuplog/pickuplog_provider.dart b/lib/providers/pickuplog/pickuplog_provider.dart index b7ddff0..652ae2c 100644 --- a/lib/providers/pickuplog/pickuplog_provider.dart +++ b/lib/providers/pickuplog/pickuplog_provider.dart @@ -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 diff --git a/lib/providers/summary/summary.dart b/lib/providers/summary/summary.dart index dab5fb8..fb48211 100644 --- a/lib/providers/summary/summary.dart +++ b/lib/providers/summary/summary.dart @@ -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 fetchSummaryStats(int userId) => _fetchSummaryStatsNew(); /// `GET /miler/earnings?period=daily|weekly|monthly` diff --git a/lib/views/Dashboard/home/homepage.dart b/lib/views/Dashboard/home/homepage.dart index 07771f5..a7a5d5a 100644 --- a/lib/views/Dashboard/home/homepage.dart +++ b/lib/views/Dashboard/home/homepage.dart @@ -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 Future _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); } } diff --git a/lib/views/Dashboard/pickups/map.dart b/lib/views/Dashboard/pickups/map.dart index 0af3384..3eae0bb 100644 --- a/lib/views/Dashboard/pickups/map.dart +++ b/lib/views/Dashboard/pickups/map.dart @@ -932,7 +932,15 @@ class _PickupMapScreenState extends State<_PickupMapScreen> final top = media.padding.top + 72; await _camera.fit( [_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. diff --git a/lib/views/helpers/constants/apiconstants.dart b/lib/views/helpers/constants/apiconstants.dart deleted file mode 100644 index f90e574..0000000 --- a/lib/views/helpers/constants/apiconstants.dart +++ /dev/null @@ -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'; -} diff --git a/lib/xpress/background/backgroundservice.dart b/lib/xpress/background/backgroundservice.dart index 0cc4bea..bd6de42 100644 --- a/lib/xpress/background/backgroundservice.dart +++ b/lib/xpress/background/backgroundservice.dart @@ -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>().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> _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 - ? (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 remaining = []; bool anySuccess = false; - final url = ApiConstants.mainRoute == 'live' - ? ApiConstants.createDeliveryLogLive - : ApiConstants.createDeliveryLogDev; - for (final itemStr in queue) { try { final Map 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? 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 = { - "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 (_) {} diff --git a/lib/xpress/background/foreground_service.dart b/lib/xpress/background/foreground_service.dart index b64c82c..f532087 100644 --- a/lib/xpress/background/foreground_service.dart +++ b/lib/xpress/background/foreground_service.dart @@ -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) diff --git a/lib/xpress/controllers/riderlog.dart b/lib/xpress/controllers/riderlog.dart index 9732bbb..4831dd1 100644 --- a/lib/xpress/controllers/riderlog.dart +++ b/lib/xpress/controllers/riderlog.dart @@ -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 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 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 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 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 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 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`, 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 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 = { "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!'); diff --git a/lib/xpress/data/delivery_api.dart b/lib/xpress/data/delivery_api.dart index 39652ad..ec1cdd0 100644 --- a/lib/xpress/data/delivery_api.dart +++ b/lib/xpress/data/delivery_api.dart @@ -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'); diff --git a/lib/xpress/providers/Riderlog/riderlog_provider.dart b/lib/xpress/providers/Riderlog/riderlog_provider.dart index 111622a..bd77a85 100644 --- a/lib/xpress/providers/Riderlog/riderlog_provider.dart +++ b/lib/xpress/providers/Riderlog/riderlog_provider.dart @@ -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 = {}; +/// 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(socketFuture, () {}); - } - - return Socket.startConnect(target ?? InternetAddress(host), port); - }; - - return IOClient(httpClient); -} - -class CreateRiderLogProvider { - Future?> createRiderLog( - String urldata, - Map 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; - } 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?> updateRiderLog( - String urldata, - Map 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) { - 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?> getRiderLog(String urldata) async { - Map? 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; - } catch (e) { - debugPrint(e.toString()); - } - return getRiderLogResponse; - } - - Future?> getRiderCount(String urldata) async { - Map? 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; - } catch (e) { - debugPrint(e.toString()); - } - return getRiderCountResponse; - } -} - -class BreakRiderLogProvider { - Future?> createBreakRiderLog( - String urldata, - Map data, - ) async { - Map? 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; - } catch (e) { - debugPrint(e.toString()); - } - return breakLogResponse; - } - - Future?> updateBreakRiderLog( - String urldata, - Map data, - ) async { - Map? 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; - } catch (e) { - debugPrint(e.toString()); - } - return breakLogResponse; - } -} +export 'package:miler/providers/Riderlog/riderlog_provider.dart' + show + BreakRiderLogProvider, + CreateRiderLogProvider, + GetRiderLogProvider, + UpdateRiderLogProvider, + unawaited; diff --git a/lib/xpress/providers/deliverylog/deliverylog_provider.dart b/lib/xpress/providers/deliverylog/deliverylog_provider.dart index ab886d4..10151ac 100644 --- a/lib/xpress/providers/deliverylog/deliverylog_provider.dart +++ b/lib/xpress/providers/deliverylog/deliverylog_provider.dart @@ -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 = {}; - -/// 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(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> _headers() => ApiConfig.authHeaders(); class CreateDeliveryLogProvider { Future?> createDeliveryLog( @@ -70,19 +28,12 @@ class CreateDeliveryLogProvider { bool wrapInArray = true, }) async { Map? 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? 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? 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? 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? 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?> getDeliveryLog(String urldata) async { Map? result; - final client = _buildSslBypassClient(); + final client = http.Client(); try { final url = Uri.parse(urldata); final response = await client.get( diff --git a/lib/xpress/views/Dashboard/home/homepage.dart b/lib/xpress/views/Dashboard/home/homepage.dart index debff5b..9ed2779 100644 --- a/lib/xpress/views/Dashboard/home/homepage.dart +++ b/lib/xpress/views/Dashboard/home/homepage.dart @@ -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 Future _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); } } diff --git a/test/duty_heartbeat_gate_test.dart b/test/duty_heartbeat_gate_test.dart new file mode 100644 index 0000000..fff08de --- /dev/null +++ b/test/duty_heartbeat_gate_test.dart @@ -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 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 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 dutyStartBody = const {}, + int dutyStartStatus = 200, + Map dutyCurrentBody = const {'onduty': true}, + }) { + sent = []; + MilerApi.client = MockClient((req) async { + sent.add(req); + final path = req.url.path; + Map 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 to(String suffix) => + sent.where((r) => r.url.path.endsWith(suffix)).toList(); + + Map bodyOf(http.Request r) => + jsonDecode(r.body) as Map; + + /// `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 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); + }); + }); +} diff --git a/test/heartbeat_interval_test.dart b/test/heartbeat_interval_test.dart new file mode 100644 index 0000000..44686f1 --- /dev/null +++ b/test/heartbeat_interval_test.dart @@ -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 [ + null, + 0, + -1, + '', + 'x', + 0.0, + -0.5, + [], + ]) { + 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 body = json.decode(res.body); + final Map 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); + }); + }); +} diff --git a/test/miler_log_contract_test.dart b/test/miler_log_contract_test.dart new file mode 100644 index 0000000..7499066 --- /dev/null +++ b/test/miler_log_contract_test.dart @@ -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 sent; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + sent = []; + 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> 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; + } + + 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(), + 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()); + expect(body['is_background'], isA()); + 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>()); + expect( + jsonDecode(sent.single.body), + isNot(isA()), + 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([ + '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)); + }); + }); +}