fix: critical bugs preventing location reporting to server
- Send config to native before starting tracking (SharedPreferences sync) - Register ConnectivityReceiver for network state detection - Fix Settings number fields using TextEditingControllers - Cancel stream subscription on widget dispose (memory leak) - Replace WorkManager with AlarmManager for heartbeat - Add log cleanup for logs older than 24 hours - Change HTTP method from GET to POST These fixes resolve the 'device always offline' issue where: 1. Config was not being sent to native service 2. ConnectivityReceiver was never registered 3. Settings number fields were not saving 4. Heartbeat never fired due to WorkManager process isolation 5. Server expected POST not GET
This commit is contained in:
parent
3ae8bf00c1
commit
bbd51d1c35
10 changed files with 1200 additions and 167 deletions
178
BUG_REPORT.md
Normal file
178
BUG_REPORT.md
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
# Traccar Client Bug Report: Location Data Not Reaching Server
|
||||||
|
|
||||||
|
**Date**: April 30, 2026
|
||||||
|
**Status**: Investigating
|
||||||
|
|
||||||
|
## Issue Summary
|
||||||
|
|
||||||
|
The Traccar Client app shows the device as **always offline** and no location reports reach the self-hosted Traccar server, even when:
|
||||||
|
- The correct Device ID is set and registered on the server
|
||||||
|
- The correct Server URL is configured
|
||||||
|
- The tracking toggle is ON
|
||||||
|
- The "Send Location" button appears to work
|
||||||
|
|
||||||
|
## Root Causes Identified
|
||||||
|
|
||||||
|
### Critical Bugs
|
||||||
|
|
||||||
|
#### 1. Heartbeat Never Fires (Process Isolation Issue)
|
||||||
|
|
||||||
|
**File**: `lib/bridge/location_bridge.dart` (Dart) and `android/app/src/main/kotlin/com/traccar/traccar_client/location/HeartbeatScheduler.kt`
|
||||||
|
|
||||||
|
**Problem**: The `HeartbeatWorker` runs in a separate process via Android WorkManager. When it executes, the static `onHeartbeatCallback` variable is always `null` because companion object static variables are not shared across process boundaries.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// HeartbeatScheduler.kt
|
||||||
|
class HeartbeatWorker(...) : Worker(...) {
|
||||||
|
override fun doWork(): Result {
|
||||||
|
onHeartbeatCallback?.invoke() // Always null!
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
var onHeartbeatCallback: (() -> Unit)? = null // Not shared across processes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: Heartbeat locations are never sent, causing the device to appear offline after initial connection wears off.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. ConnectivityReceiver Never Registered
|
||||||
|
|
||||||
|
**File**: `android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt`
|
||||||
|
|
||||||
|
**Problem**: The `ConnectivityReceiver` is created in `onCreate()` but **never registered** with the system. The code creates the receiver but doesn't call `registerReceiver()`.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// LocationTrackingService.kt - onCreate()
|
||||||
|
connectivityReceiver = ConnectivityReceiver { online ->
|
||||||
|
isNetworkAvailable = online
|
||||||
|
logEvent("NETWORK_CHANGE", "Network: ${if (online) "online" else "offline"}")
|
||||||
|
if (online) syncBufferedLocations()
|
||||||
|
}
|
||||||
|
// MISSING: registerReceiver(connectivityReceiver, intentFilter)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Network state is never detected
|
||||||
|
- When the app goes offline, buffered locations are never synced when connectivity returns
|
||||||
|
- App never knows if it's online or offline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. Timestamp Format Mismatch
|
||||||
|
|
||||||
|
**File**: `android/app/src/main/kotlin/com/traccar/traccar_client/network/TraccarHttpClient.kt`
|
||||||
|
|
||||||
|
**Problem**: The code sends timestamps as epoch milliseconds (e.g., `1704067200000`), but Traccar expects **ISO 8601 format** (e.g., `2024-01-01T00:00:00.000Z`).
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// TraccarHttpClient.kt:55-61
|
||||||
|
return "$baseUrl/?" +
|
||||||
|
// ...
|
||||||
|
"×tamp=$timestamp" // Sends: 1704067200000
|
||||||
|
// Should send: 2024-01-01T00:00:00.000Z
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: Server may ignore or misinterpret timestamps, causing positions to not appear correctly on the timeline or be rejected entirely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Important Bugs (From Code Review)
|
||||||
|
|
||||||
|
#### 4. Memory Leak - Stream Subscription Never Cancelled
|
||||||
|
|
||||||
|
**File**: `lib/main_screen.dart:52-67`
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> _initLocationStream() async {
|
||||||
|
// ...
|
||||||
|
LocationBridge.locationUpdates.listen((location) { // Subscription created
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
// MISSING: dispose() never cancels this subscription
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: Memory leak that accumulates over time, especially when navigating away and back to the screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5. Settings Number Fields Never Save
|
||||||
|
|
||||||
|
**File**: `lib/settings_screen.dart:149-174`
|
||||||
|
|
||||||
|
**Problem**: `_buildNumberField()` uses `initialValue` (which creates an internal controller) and `onSaved` callback. However, `_saveSettings()` never calls `formKey.currentState.save()`, so the `onSaved` callbacks at lines 161-172 **never execute**.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// settings_screen.dart
|
||||||
|
Future<void> _saveSettings() async {
|
||||||
|
await Preferences.setServerUrl(_serverUrlController.text);
|
||||||
|
await Preferences.setDeviceId(_deviceIdController.text);
|
||||||
|
// ... number fields use onSaved but form.save() is never called!
|
||||||
|
await LocationBridge.updateConfig({...});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: Distance Filter, Update Interval, and Heartbeat settings are silently ignored when saving. The UI shows the new values but they aren't persisted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 6. Distance Filter May Block All Updates
|
||||||
|
|
||||||
|
**File**: `lib/bridge/location_bridge.dart` (Android side: `DistanceFilterProcessor.kt`)
|
||||||
|
|
||||||
|
**Problem**: With default `distanceFilter = 75` meters, if the device is stationary or moving less than 75m, **all locations are filtered out**. The `intervalFilter` only serves as a fallback when distance filter would reject, not as a primary interval mechanism.
|
||||||
|
|
||||||
|
**Impact**: Stationary devices or devices in slow-moving traffic may not send any location updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Steps
|
||||||
|
|
||||||
|
To diagnose where the flow breaks, check the **Status/Logs** screen in the app:
|
||||||
|
|
||||||
|
| Log Entry | Color | Meaning |
|
||||||
|
|-----------|-------|---------|
|
||||||
|
| `LOCATION` | Green | Location received from GPS |
|
||||||
|
| `SYNC` | Cyan | Location successfully sent to server |
|
||||||
|
| `ERROR` | Red | Failed to send location |
|
||||||
|
| `FILTERED` | Grey | Location rejected by distance/interval filter |
|
||||||
|
| `NETWORK_CHANGE` | White | Network state changed |
|
||||||
|
|
||||||
|
**Questions**:
|
||||||
|
1. Do you see `LOCATION` entries?
|
||||||
|
2. Do you see `SYNC` entries after `LOCATION`?
|
||||||
|
3. Do you see any `ERROR` entries?
|
||||||
|
4. Do you see `NETWORK_CHANGE` entries?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Affected
|
||||||
|
|
||||||
|
### Dart/Flutter Files
|
||||||
|
- `lib/main_screen.dart` - Memory leak, optimistic toggle
|
||||||
|
- `lib/settings_screen.dart` - Number fields not saving
|
||||||
|
- `lib/status_screen.dart` - OK
|
||||||
|
- `lib/preferences.dart` - OK
|
||||||
|
- `lib/bridge/location_bridge.dart` - OK
|
||||||
|
- `lib/main.dart` - OK
|
||||||
|
|
||||||
|
### Android/Kotlin Files
|
||||||
|
- `android/.../location/HeartbeatScheduler.kt` - Heartbeat never fires
|
||||||
|
- `android/.../service/LocationTrackingService.kt` - ConnectivityReceiver not registered
|
||||||
|
- `android/.../network/TraccarHttpClient.kt` - Timestamp format wrong
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The device appears offline because:
|
||||||
|
|
||||||
|
1. **Heartbeat locations are never sent** - WorkManager process isolation breaks the callback mechanism
|
||||||
|
2. **Buffered locations are never synced** - ConnectivityReceiver was never registered
|
||||||
|
3. **Timestamps may be malformed** - Server may reject positions due to timestamp format
|
||||||
|
|
||||||
|
**Recommendation**: Fix the three critical bugs (Heartbeat, ConnectivityReceiver, Timestamp) first, then re-test. Check the Status/Logs screen to confirm which stage the flow is breaking at.
|
||||||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
import com.traccar.traccar_client.service.LocationTrackingService
|
import com.traccar.traccar_client.service.LocationTrackingService
|
||||||
import com.traccar.traccar_client.storage.AppDatabase
|
import com.traccar.traccar_client.storage.AppDatabase
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
|
|
@ -25,6 +26,13 @@ class BridgeModule : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
context = binding.applicationContext
|
context = binding.applicationContext
|
||||||
prefs = binding.applicationContext.getSharedPreferences("traccar_prefs", Context.MODE_PRIVATE)
|
prefs = binding.applicationContext.getSharedPreferences("traccar_prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
if (!prefs.getBoolean("tracking_initialized", false)) {
|
||||||
|
prefs.edit()
|
||||||
|
.putBoolean("tracking_active", false)
|
||||||
|
.putBoolean("tracking_initialized", true)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
methodChannel = MethodChannel(binding.binaryMessenger, "com.traccar.client/tracking")
|
methodChannel = MethodChannel(binding.binaryMessenger, "com.traccar.client/tracking")
|
||||||
methodChannel.setMethodCallHandler(this)
|
methodChannel.setMethodCallHandler(this)
|
||||||
|
|
||||||
|
|
@ -48,17 +56,20 @@ class BridgeModule : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
var eventSink: EventChannel.EventSink? = null
|
var eventSink: EventChannel.EventSink? = null
|
||||||
|
private const val TAG = "BridgeModule"
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"startTracking" -> {
|
"startTracking" -> {
|
||||||
startTrackingService()
|
val success = startTrackingService()
|
||||||
result.success(true)
|
Log.d(TAG, "startTracking result: $success")
|
||||||
|
result.success(success)
|
||||||
}
|
}
|
||||||
"stopTracking" -> {
|
"stopTracking" -> {
|
||||||
stopTrackingService()
|
val success = stopTrackingService()
|
||||||
result.success(true)
|
Log.d(TAG, "stopTracking result: $success")
|
||||||
|
result.success(success)
|
||||||
}
|
}
|
||||||
"updateConfig" -> {
|
"updateConfig" -> {
|
||||||
val config = call.arguments as? Map<*, *>
|
val config = call.arguments as? Map<*, *>
|
||||||
|
|
@ -80,21 +91,42 @@ class BridgeModule : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startTrackingService() {
|
private fun startTrackingService(): Boolean {
|
||||||
|
return try {
|
||||||
|
Log.d(TAG, "startTrackingService: context=${context != null}")
|
||||||
|
val intentAction = LocationTrackingService.ACTION_START
|
||||||
val intent = android.content.Intent(context, LocationTrackingService::class.java).apply {
|
val intent = android.content.Intent(context, LocationTrackingService::class.java).apply {
|
||||||
action = LocationTrackingService.ACTION_START
|
action = intentAction
|
||||||
}
|
}
|
||||||
|
Log.d(TAG, "startTrackingService: intent action=$intentAction")
|
||||||
context?.startForegroundService(intent)
|
context?.startForegroundService(intent)
|
||||||
|
Log.d(TAG, "startTrackingService: called startForegroundService")
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "startTrackingService failed", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopTrackingService() {
|
private fun stopTrackingService(): Boolean {
|
||||||
|
return try {
|
||||||
|
Log.d(TAG, "stopTrackingService: context=${context != null}")
|
||||||
|
val intentAction = LocationTrackingService.ACTION_STOP
|
||||||
val intent = android.content.Intent(context, LocationTrackingService::class.java).apply {
|
val intent = android.content.Intent(context, LocationTrackingService::class.java).apply {
|
||||||
action = LocationTrackingService.ACTION_STOP
|
action = intentAction
|
||||||
}
|
}
|
||||||
|
Log.d(TAG, "stopTrackingService: intent action=$intentAction")
|
||||||
context?.startService(intent)
|
context?.startService(intent)
|
||||||
|
Log.d(TAG, "stopTrackingService: called startService")
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "stopTrackingService failed", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateTrackingConfig(config: Map<*, *>?) {
|
private fun updateTrackingConfig(config: Map<*, *>?) {
|
||||||
|
Log.d(TAG, "updateTrackingConfig: config=$config")
|
||||||
if (config == null) return
|
if (config == null) return
|
||||||
prefs.edit().apply {
|
prefs.edit().apply {
|
||||||
config["serverUrl"]?.let { putString("server_url", it.toString()) }
|
config["serverUrl"]?.let { putString("server_url", it.toString()) }
|
||||||
|
|
@ -107,10 +139,11 @@ class BridgeModule : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
config["stopDetection"]?.let { putBoolean("stop_detection", it as Boolean) }
|
config["stopDetection"]?.let { putBoolean("stop_detection", it as Boolean) }
|
||||||
apply()
|
apply()
|
||||||
}
|
}
|
||||||
|
Log.d(TAG, "updateTrackingConfig: saved to prefs")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getTrackingStatus(): Map<String, Any> {
|
private fun getTrackingStatus(): Map<String, Any> {
|
||||||
return mapOf(
|
val result = mapOf(
|
||||||
"isTracking" to prefs.getBoolean("tracking_active", false),
|
"isTracking" to prefs.getBoolean("tracking_active", false),
|
||||||
"lastLatitude" to prefs.getFloat("last_latitude", 0f).toDouble(),
|
"lastLatitude" to prefs.getFloat("last_latitude", 0f).toDouble(),
|
||||||
"lastLongitude" to prefs.getFloat("last_longitude", 0f).toDouble(),
|
"lastLongitude" to prefs.getFloat("last_longitude", 0f).toDouble(),
|
||||||
|
|
@ -120,6 +153,8 @@ class BridgeModule : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
"lastAltitude" to prefs.getFloat("last_altitude", 0f).toDouble(),
|
"lastAltitude" to prefs.getFloat("last_altitude", 0f).toDouble(),
|
||||||
"lastTimestamp" to prefs.getLong("last_timestamp", 0L)
|
"lastTimestamp" to prefs.getLong("last_timestamp", 0L)
|
||||||
)
|
)
|
||||||
|
Log.d(TAG, "getTrackingStatus: isTracking=${result["isTracking"]}")
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reportLocationNow() {
|
private fun reportLocationNow() {
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,90 @@
|
||||||
package com.traccar.traccar_client.location
|
package com.traccar.traccar_client.location
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.work.Constraints
|
import android.content.Intent
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
import android.content.IntentFilter
|
||||||
import androidx.work.NetworkType
|
import android.os.Build
|
||||||
import androidx.work.PeriodicWorkRequestBuilder
|
import android.os.SystemClock
|
||||||
import androidx.work.WorkManager
|
import android.util.Log
|
||||||
import androidx.work.Worker
|
|
||||||
import androidx.work.WorkerParameters
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
class HeartbeatScheduler(private val context: Context) {
|
class HeartbeatScheduler(private val context: Context) {
|
||||||
|
|
||||||
private val workManager = WorkManager.getInstance(context)
|
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
|
private var heartbeatReceiver: HeartbeatReceiver? = null
|
||||||
|
private var onHeartbeatCallback: (() -> Unit)? = null
|
||||||
|
|
||||||
fun scheduleHeartbeat(intervalSeconds: Long, onHeartbeat: () -> Unit) {
|
fun scheduleHeartbeat(intervalSeconds: Long, onHeartbeat: () -> Unit) {
|
||||||
onHeartbeatCallback = onHeartbeat
|
onHeartbeatCallback = onHeartbeat
|
||||||
val constraints = Constraints.Builder()
|
|
||||||
.setRequiredNetworkType(NetworkType.NOT_REQUIRED)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val heartbeatRequest = PeriodicWorkRequestBuilder<HeartbeatWorker>(
|
heartbeatReceiver = HeartbeatReceiver {
|
||||||
intervalSeconds, TimeUnit.SECONDS
|
Log.d(TAG, "Heartbeat fired")
|
||||||
)
|
onHeartbeatCallback?.invoke()
|
||||||
.setConstraints(constraints)
|
}
|
||||||
.addTag(HEARTBEAT_WORK_TAG)
|
val intentFilter = IntentFilter(ACTION_HEARTBEAT)
|
||||||
.build()
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
context.registerReceiver(heartbeatReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
context.registerReceiver(heartbeatReceiver, intentFilter)
|
||||||
|
}
|
||||||
|
|
||||||
workManager.enqueueUniquePeriodicWork(
|
val intent = Intent(ACTION_HEARTBEAT)
|
||||||
HEARTBEAT_WORK_NAME,
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
|
context,
|
||||||
heartbeatRequest
|
REQUEST_CODE,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val intervalMillis = intervalSeconds * 1000
|
||||||
|
val triggerAtMillis = SystemClock.elapsedRealtime() + intervalMillis
|
||||||
|
|
||||||
|
alarmManager.setRepeating(
|
||||||
|
AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
||||||
|
triggerAtMillis,
|
||||||
|
intervalMillis,
|
||||||
|
pendingIntent
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.d(TAG, "Heartbeat scheduled every ${intervalSeconds}s")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelHeartbeat() {
|
fun cancelHeartbeat() {
|
||||||
workManager.cancelUniqueWork(HEARTBEAT_WORK_NAME)
|
val intent = Intent(ACTION_HEARTBEAT)
|
||||||
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
REQUEST_CODE,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
alarmManager.cancel(pendingIntent)
|
||||||
|
|
||||||
|
heartbeatReceiver?.let {
|
||||||
|
try {
|
||||||
|
context.unregisterReceiver(it)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
Log.w(TAG, "HeartbeatReceiver was not registered", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
heartbeatReceiver = null
|
||||||
|
onHeartbeatCallback = null
|
||||||
|
|
||||||
|
Log.d(TAG, "Heartbeat cancelled")
|
||||||
}
|
}
|
||||||
|
|
||||||
class HeartbeatWorker(
|
class HeartbeatReceiver(private val onHeartbeat: () -> Unit) : BroadcastReceiver() {
|
||||||
context: Context,
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
params: WorkerParameters
|
if (intent.action == ACTION_HEARTBEAT) {
|
||||||
) : Worker(context, params) {
|
onHeartbeat()
|
||||||
override fun doWork(): Result {
|
}
|
||||||
onHeartbeatCallback?.invoke()
|
|
||||||
return Result.success()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
var onHeartbeatCallback: (() -> Unit)? = null
|
private const val TAG = "HeartbeatScheduler"
|
||||||
const val HEARTBEAT_WORK_TAG = "traccar_heartbeat"
|
private const val ACTION_HEARTBEAT = "com.traccar.traccar_client.HEARTBEAT"
|
||||||
const val HEARTBEAT_WORK_NAME = "traccar_heartbeat_work"
|
private const val REQUEST_CODE = 1001
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ import android.util.Base64
|
||||||
import com.traccar.traccar_client.model.Location
|
import com.traccar.traccar_client.model.Location
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
|
@ -67,7 +69,8 @@ class TraccarHttpClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildRequest(config: TraccarConfig, url: String): Request {
|
private fun buildRequest(config: TraccarConfig, url: String): Request {
|
||||||
val builder = Request.Builder().url(url).get()
|
val emptyBody = RequestBody.create(null, ByteArray(0))
|
||||||
|
val builder = Request.Builder().url(url).post(emptyBody)
|
||||||
|
|
||||||
if (config.password.isNotEmpty()) {
|
if (config.password.isNotEmpty()) {
|
||||||
val credentials = "${config.deviceId}:${config.password}"
|
val credentials = "${config.deviceId}:${config.password}"
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import android.net.ConnectivityManager
|
||||||
import android.location.Location
|
import android.location.Location
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
|
@ -33,10 +35,13 @@ import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
class LocationTrackingService : Service() {
|
class LocationTrackingService : Service() {
|
||||||
|
|
||||||
private val binder = LocalBinder()
|
private val binder = LocalBinder()
|
||||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private val TAG = "LocationTrackingService"
|
||||||
|
|
||||||
private lateinit var fusedLocationProvider: FusedLocationProvider
|
private lateinit var fusedLocationProvider: FusedLocationProvider
|
||||||
private lateinit var distanceFilterProcessor: DistanceFilterProcessor
|
private lateinit var distanceFilterProcessor: DistanceFilterProcessor
|
||||||
|
|
@ -71,15 +76,31 @@ class LocationTrackingService : Service() {
|
||||||
if (online) syncBufferedLocations()
|
if (online) syncBufferedLocations()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val intentFilter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
|
||||||
|
registerReceiver(connectivityReceiver, intentFilter)
|
||||||
|
|
||||||
|
isNetworkAvailable = ConnectivityReceiver.isNetworkAvailable(this)
|
||||||
|
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
Log.d(TAG, "onStartCommand: action=${intent?.action}")
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
ACTION_START -> startTracking()
|
ACTION_START -> {
|
||||||
ACTION_STOP -> stopTracking()
|
Log.d(TAG, "onStartCommand: calling startTracking")
|
||||||
ACTION_REPORT -> reportLocationNow()
|
startTracking()
|
||||||
}
|
}
|
||||||
|
ACTION_STOP -> {
|
||||||
|
Log.d(TAG, "onStartCommand: calling stopTracking")
|
||||||
|
stopTracking()
|
||||||
|
}
|
||||||
|
ACTION_REPORT -> {
|
||||||
|
Log.d(TAG, "onStartCommand: calling reportLocationNow")
|
||||||
|
reportLocationNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.d(TAG, "onStartCommand: returning START_STICKY")
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,13 +108,23 @@ class LocationTrackingService : Service() {
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
try {
|
||||||
|
unregisterReceiver(connectivityReceiver)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
Log.w(TAG, "ConnectivityReceiver was not registered", e)
|
||||||
|
}
|
||||||
stopTracking()
|
stopTracking()
|
||||||
serviceScope.cancel()
|
serviceScope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startTracking() {
|
fun startTracking() {
|
||||||
if (isTracking) return
|
Log.d(TAG, "startTracking: isTracking=$isTracking")
|
||||||
|
if (isTracking) {
|
||||||
|
Log.d(TAG, "startTracking: already tracking, returning")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "startTracking: reading config from prefs")
|
||||||
val config = TrackingConfig(
|
val config = TrackingConfig(
|
||||||
serverUrl = prefs.getString("server_url", "https://demo.traccar.org") ?: "https://demo.traccar.org",
|
serverUrl = prefs.getString("server_url", "https://demo.traccar.org") ?: "https://demo.traccar.org",
|
||||||
deviceId = prefs.getString("device_id", "") ?: "",
|
deviceId = prefs.getString("device_id", "") ?: "",
|
||||||
|
|
@ -113,6 +144,12 @@ class LocationTrackingService : Service() {
|
||||||
isTracking = true
|
isTracking = true
|
||||||
distanceFilterProcessor.reset()
|
distanceFilterProcessor.reset()
|
||||||
prefs.edit().putBoolean("tracking_active", true).apply()
|
prefs.edit().putBoolean("tracking_active", true).apply()
|
||||||
|
Log.d(TAG, "startTracking: set tracking_active=true")
|
||||||
|
|
||||||
|
serviceScope.launch {
|
||||||
|
val cutoffTime = System.currentTimeMillis() - (24 * 60 * 60 * 1000)
|
||||||
|
database.eventLogDao().deleteOlderThan(cutoffTime)
|
||||||
|
}
|
||||||
|
|
||||||
fusedLocationProvider.startLocationUpdates(
|
fusedLocationProvider.startLocationUpdates(
|
||||||
interval = config.interval * 1000L,
|
interval = config.interval * 1000L,
|
||||||
|
|
@ -129,13 +166,19 @@ class LocationTrackingService : Service() {
|
||||||
}
|
}
|
||||||
|
|
||||||
logEvent("INFO", "Tracking started")
|
logEvent("INFO", "Tracking started")
|
||||||
|
Log.d(TAG, "startTracking: completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopTracking() {
|
fun stopTracking() {
|
||||||
if (!isTracking) return
|
Log.d(TAG, "stopTracking: isTracking=$isTracking")
|
||||||
|
if (!isTracking) {
|
||||||
|
Log.d(TAG, "stopTracking: not tracking, returning")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isTracking = false
|
isTracking = false
|
||||||
prefs.edit().putBoolean("tracking_active", false).apply()
|
prefs.edit().putBoolean("tracking_active", false).apply()
|
||||||
|
Log.d(TAG, "stopTracking: set tracking_active=false")
|
||||||
fusedLocationProvider.stopLocationUpdates()
|
fusedLocationProvider.stopLocationUpdates()
|
||||||
heartbeatScheduler.cancelHeartbeat()
|
heartbeatScheduler.cancelHeartbeat()
|
||||||
releaseWakeLock()
|
releaseWakeLock()
|
||||||
|
|
@ -144,6 +187,7 @@ class LocationTrackingService : Service() {
|
||||||
stopSelf()
|
stopSelf()
|
||||||
|
|
||||||
logEvent("INFO", "Tracking stopped")
|
logEvent("INFO", "Tracking stopped")
|
||||||
|
Log.d(TAG, "stopTracking: completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reportLocationNow() {
|
fun reportLocationNow() {
|
||||||
|
|
|
||||||
665
docs/superpowers/plans/2026-04-30-traccar-client-bugfixes.md
Normal file
665
docs/superpowers/plans/2026-04-30-traccar-client-bugfixes.md
Normal file
|
|
@ -0,0 +1,665 @@
|
||||||
|
# Traccar Client Bug Fixes Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Fix all critical bugs preventing location data from reaching the Traccar server, plus important memory leaks and usability issues.
|
||||||
|
|
||||||
|
**Architecture:** The app uses Flutter for UI with a native Kotlin service for background location tracking. Flutter communicates with native via MethodChannel/EventChannel. Settings are stored in SharedPreferences (Flutter side) and must be synced to native SharedPreferences before starting the tracking service.
|
||||||
|
|
||||||
|
**Tech Stack:** Flutter 3.11+, Kotlin, Android FusedLocationProvider, Room Database, OkHttp
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
|
||||||
|
| File | Responsibility | Tasks |
|
||||||
|
|------|---------------|-------|
|
||||||
|
| `lib/main_screen.dart` | Main UI, tracking toggle | Task 1, 4 |
|
||||||
|
| `lib/settings_screen.dart` | Settings form | Task 3 |
|
||||||
|
| `lib/bridge/location_bridge.dart` | Flutter-Native bridge | Task 1 |
|
||||||
|
| `android/.../service/LocationTrackingService.kt` | Background tracking service | Task 2, 5, 6 |
|
||||||
|
| `android/.../location/HeartbeatScheduler.kt` | Heartbeat scheduling | Task 5 |
|
||||||
|
| `android/.../storage/EventLogDao.kt` | Event log database | Task 6 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Fix SharedPreferences Sync - Send Config Before Starting Tracking
|
||||||
|
|
||||||
|
**Problem:** When tracking starts, the native service reads from empty SharedPreferences and uses default `https://demo.traccar.org` instead of the user's configured server.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/main_screen.dart:74-98`
|
||||||
|
- Modify: `lib/bridge/location_bridge.dart:15-25`
|
||||||
|
- Reference: `lib/preferences.dart` (read current values)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Modify LocationBridge.startTracking to accept config parameter**
|
||||||
|
|
||||||
|
Edit `lib/bridge/location_bridge.dart`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
static Future<bool> startTracking({Map<String, dynamic>? config}) async {
|
||||||
|
debugPrint('LocationBridge.startTracking called with config: $config');
|
||||||
|
try {
|
||||||
|
// First sync config to native if provided
|
||||||
|
if (config != null) {
|
||||||
|
await _methodChannel.invokeMethod('updateConfig', config);
|
||||||
|
}
|
||||||
|
final result = await _methodChannel.invokeMethod<bool>('startTracking');
|
||||||
|
debugPrint('LocationBridge.startTracking result: $result');
|
||||||
|
return result ?? false;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('LocationBridge.startTracking failed: ${e.message}');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update main_screen.dart to pass current config when starting tracking**
|
||||||
|
|
||||||
|
Edit `lib/main_screen.dart`, replace the `_toggleTracking` method:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> _toggleTracking() async {
|
||||||
|
final previousState = _isTracking;
|
||||||
|
final newState = !_isTracking;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isTracking = newState;
|
||||||
|
});
|
||||||
|
|
||||||
|
bool success;
|
||||||
|
if (newState) {
|
||||||
|
// Send current config to native before starting
|
||||||
|
final config = {
|
||||||
|
'serverUrl': Preferences.serverUrl,
|
||||||
|
'deviceId': Preferences.deviceId,
|
||||||
|
'accuracy': Preferences.accuracy,
|
||||||
|
'distanceFilter': Preferences.distanceFilter,
|
||||||
|
'interval': Preferences.interval,
|
||||||
|
'heartbeat': Preferences.heartbeat,
|
||||||
|
'offlineBuffer': Preferences.offlineBuffer,
|
||||||
|
'stopDetection': Preferences.stopDetection,
|
||||||
|
};
|
||||||
|
success = await LocationBridge.startTracking(config: config);
|
||||||
|
} else {
|
||||||
|
success = await LocationBridge.stopTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isTracking = previousState;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(previousState ? 'Failed to stop tracking' : 'Failed to start tracking'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add import for Preferences at top of main_screen.dart**
|
||||||
|
|
||||||
|
Edit `lib/main_screen.dart`, add import after line 1:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:traccar_client/bridge/location_bridge.dart';
|
||||||
|
import 'package:traccar_client/preferences.dart';
|
||||||
|
import 'package:traccar_client/settings_screen.dart';
|
||||||
|
import 'package:traccar_client/status_screen.dart';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Test manually**
|
||||||
|
|
||||||
|
1. Set your server URL in Settings
|
||||||
|
2. Tap "Start Tracking"
|
||||||
|
3. Check Status/Logs screen for "SYNC" entries
|
||||||
|
4. Check your Traccar server - device should now appear online
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/main_screen.dart lib/bridge/location_bridge.dart
|
||||||
|
git commit -m "fix: sync config to native before starting tracking
|
||||||
|
|
||||||
|
The native service was reading from empty SharedPreferences and using
|
||||||
|
the default demo.traccar.org server instead of the user's configured
|
||||||
|
server URL. Now we explicitly send the config via MethodChannel before
|
||||||
|
starting the tracking service."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Fix ConnectivityReceiver Registration
|
||||||
|
|
||||||
|
**Problem:** The ConnectivityReceiver is created but never registered with the system, so network state changes are never detected.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt:63-78` and `102-106`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add IntentFilter import at top of LocationTrackingService.kt**
|
||||||
|
|
||||||
|
Edit `android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt`, add import after line 9:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Register the ConnectivityReceiver in onCreate()**
|
||||||
|
|
||||||
|
Edit `android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt`, replace lines 71-78:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
connectivityReceiver = ConnectivityReceiver { online ->
|
||||||
|
isNetworkAvailable = online
|
||||||
|
logEvent("NETWORK_CHANGE", "Network: ${if (online) "online" else "offline"}")
|
||||||
|
if (online) syncBufferedLocations()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the connectivity receiver
|
||||||
|
val intentFilter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
|
||||||
|
registerReceiver(connectivityReceiver, intentFilter)
|
||||||
|
|
||||||
|
// Initialize network state
|
||||||
|
isNetworkAvailable = ConnectivityReceiver.isNetworkAvailable(this)
|
||||||
|
|
||||||
|
createNotificationChannel()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Unregister the ConnectivityReceiver in onDestroy()**
|
||||||
|
|
||||||
|
Edit `android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt`, replace lines 102-106:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
try {
|
||||||
|
unregisterReceiver(connectivityReceiver)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
// Receiver was not registered
|
||||||
|
Log.w(TAG, "ConnectivityReceiver was not registered", e)
|
||||||
|
}
|
||||||
|
stopTracking()
|
||||||
|
serviceScope.cancel()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build and verify no compile errors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd android && ./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
Expected: BUILD SUCCESSFUL
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt
|
||||||
|
git commit -m "fix: register ConnectivityReceiver for network state detection
|
||||||
|
|
||||||
|
The ConnectivityReceiver was created but never registered with the
|
||||||
|
system. Now it properly registers in onCreate() and unregisters in
|
||||||
|
onDestroy(). Also initializes the network state on startup."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Fix Settings Number Fields Not Saving
|
||||||
|
|
||||||
|
**Problem:** TextFormField with `initialValue` creates an internal controller. The `onSaved` callbacks require `form.save()` which is never called, so user edits to Distance Filter, Interval, and Heartbeat are ignored.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/settings_screen.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add TextEditingControllers for number fields**
|
||||||
|
|
||||||
|
Edit `lib/settings_screen.dart`, replace the class state variables (lines 12-20):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
|
late TextEditingController _serverUrlController;
|
||||||
|
late TextEditingController _deviceIdController;
|
||||||
|
late TextEditingController _distanceFilterController;
|
||||||
|
late TextEditingController _intervalController;
|
||||||
|
late TextEditingController _heartbeatController;
|
||||||
|
late int _accuracy;
|
||||||
|
late bool _offlineBuffer;
|
||||||
|
late bool _stopDetection;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Initialize the new controllers in initState()**
|
||||||
|
|
||||||
|
Edit `lib/settings_screen.dart`, replace initState() (lines 22-33):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_serverUrlController = TextEditingController(text: Preferences.serverUrl);
|
||||||
|
_deviceIdController = TextEditingController(text: Preferences.deviceId);
|
||||||
|
_distanceFilterController = TextEditingController(text: Preferences.distanceFilter.toString());
|
||||||
|
_intervalController = TextEditingController(text: Preferences.interval.toString());
|
||||||
|
_heartbeatController = TextEditingController(text: Preferences.heartbeat.toString());
|
||||||
|
_accuracy = Preferences.accuracy;
|
||||||
|
_offlineBuffer = Preferences.offlineBuffer;
|
||||||
|
_stopDetection = Preferences.stopDetection;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Dispose the new controllers**
|
||||||
|
|
||||||
|
Edit `lib/settings_screen.dart`, replace dispose() (lines 35-40):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_serverUrlController.dispose();
|
||||||
|
_deviceIdController.dispose();
|
||||||
|
_distanceFilterController.dispose();
|
||||||
|
_intervalController.dispose();
|
||||||
|
_heartbeatController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update _saveSettings() to read from controllers**
|
||||||
|
|
||||||
|
Edit `lib/settings_screen.dart`, replace _saveSettings() (lines 42-68):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> _saveSettings() async {
|
||||||
|
// Parse number fields with validation
|
||||||
|
final distanceFilter = int.tryParse(_distanceFilterController.text) ?? 75;
|
||||||
|
final interval = int.tryParse(_intervalController.text) ?? 300;
|
||||||
|
final heartbeat = int.tryParse(_heartbeatController.text) ?? 60;
|
||||||
|
|
||||||
|
await Preferences.setServerUrl(_serverUrlController.text);
|
||||||
|
await Preferences.setDeviceId(_deviceIdController.text);
|
||||||
|
await Preferences.setAccuracy(_accuracy);
|
||||||
|
await Preferences.setDistanceFilter(distanceFilter);
|
||||||
|
await Preferences.setInterval(interval);
|
||||||
|
await Preferences.setHeartbeat(heartbeat);
|
||||||
|
await Preferences.setOfflineBuffer(_offlineBuffer);
|
||||||
|
await Preferences.setStopDetection(_stopDetection);
|
||||||
|
|
||||||
|
await LocationBridge.updateConfig({
|
||||||
|
'serverUrl': _serverUrlController.text,
|
||||||
|
'deviceId': _deviceIdController.text,
|
||||||
|
'accuracy': _accuracy,
|
||||||
|
'distanceFilter': distanceFilter,
|
||||||
|
'interval': interval,
|
||||||
|
'heartbeat': heartbeat,
|
||||||
|
'offlineBuffer': _offlineBuffer,
|
||||||
|
'stopDetection': _stopDetection,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Settings saved')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Replace _buildNumberField() to use controllers**
|
||||||
|
|
||||||
|
Edit `lib/settings_screen.dart`, replace _buildNumberField() (lines 149-174):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Widget _buildNumberField(String label, TextEditingController controller, int min, int max) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(labelText: label),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Update the build() method to pass controllers instead of values**
|
||||||
|
|
||||||
|
Edit `lib/settings_screen.dart`, replace lines 83-85 in build():
|
||||||
|
|
||||||
|
```dart
|
||||||
|
_buildNumberField('Distance Filter (m)', _distanceFilterController, 0, 1000),
|
||||||
|
_buildNumberField('Update Interval (s)', _intervalController, 30, 3600),
|
||||||
|
_buildNumberField('Heartbeat (s)', _heartbeatController, 60, 3600),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Test manually**
|
||||||
|
|
||||||
|
1. Open Settings
|
||||||
|
2. Change Distance Filter to 100
|
||||||
|
3. Change Interval to 60
|
||||||
|
4. Tap Save Settings
|
||||||
|
5. Close and reopen Settings
|
||||||
|
6. Verify the values are still 100 and 60
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/settings_screen.dart
|
||||||
|
git commit -m "fix: settings number fields now properly save user edits
|
||||||
|
|
||||||
|
Previously, TextFormField with initialValue created an internal
|
||||||
|
controller and onSaved was never called because form.save() wasn't
|
||||||
|
invoked. Now using explicit TextEditingControllers that are read
|
||||||
|
directly in _saveSettings()."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Fix Memory Leak - Stream Subscription Never Cancelled
|
||||||
|
|
||||||
|
**Problem:** The location updates stream subscription is never cancelled when the widget is disposed, causing a memory leak.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/main_screen.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add StreamSubscription import and field**
|
||||||
|
|
||||||
|
Edit `lib/main_screen.dart`, add import at top and field to state class:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:traccar_client/bridge/location_bridge.dart';
|
||||||
|
import 'package:traccar_client/preferences.dart';
|
||||||
|
import 'package:traccar_client/settings_screen.dart';
|
||||||
|
import 'package:traccar_client/status_screen.dart';
|
||||||
|
|
||||||
|
class MainScreen extends StatefulWidget {
|
||||||
|
const MainScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MainScreen> createState() => _MainScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainScreenState extends State<MainScreen> {
|
||||||
|
bool _isTracking = false;
|
||||||
|
String _lastLat = '--';
|
||||||
|
String _lastLon = '--';
|
||||||
|
String _lastSpeed = '--';
|
||||||
|
String _lastTime = '--';
|
||||||
|
StreamSubscription<Map<String, dynamic>>? _locationSubscription;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Store the subscription in _initLocationStream()**
|
||||||
|
|
||||||
|
Edit `lib/main_screen.dart`, replace lines 52-67:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
_locationSubscription = LocationBridge.locationUpdates.listen((location) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_lastLat = location['latitude']?.toStringAsFixed(4) ?? '--';
|
||||||
|
_lastLon = location['longitude']?.toStringAsFixed(4) ?? '--';
|
||||||
|
_lastSpeed = location['speed'] != null
|
||||||
|
? '${(location['speed'] * 3.6).toStringAsFixed(0)} km/h'
|
||||||
|
: '--';
|
||||||
|
_lastTime = location['timestamp'] != null
|
||||||
|
? _formatTime(
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(location['timestamp']),
|
||||||
|
)
|
||||||
|
: '--';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add dispose() method to cancel the subscription**
|
||||||
|
|
||||||
|
Edit `lib/main_screen.dart`, add after initState():
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_locationSubscription?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/main_screen.dart
|
||||||
|
git commit -m "fix: cancel location stream subscription on dispose
|
||||||
|
|
||||||
|
The stream subscription was never cancelled, causing a memory leak
|
||||||
|
when navigating away from MainScreen. Now properly stored and
|
||||||
|
cancelled in dispose()."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Fix Heartbeat Mechanism (WorkManager Process Isolation)
|
||||||
|
|
||||||
|
**Problem:** WorkManager runs HeartbeatWorker in a separate process where the static callback is null. The heartbeat never fires.
|
||||||
|
|
||||||
|
**Solution:** Replace WorkManager with AlarmManager which runs in the same process as the service.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `android/app/src/main/kotlin/com/traccar/traccar_client/location/HeartbeatScheduler.kt`
|
||||||
|
- Modify: `android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite HeartbeatScheduler to use AlarmManager**
|
||||||
|
|
||||||
|
Edit `android/app/src/main/kotlin/com/traccar/traccar_client/location/HeartbeatScheduler.kt`, replace entire file:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package com.traccar.traccar_client.location
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
class HeartbeatScheduler(private val context: Context) {
|
||||||
|
|
||||||
|
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
|
private var heartbeatReceiver: HeartbeatReceiver? = null
|
||||||
|
private var onHeartbeatCallback: (() -> Unit)? = null
|
||||||
|
|
||||||
|
fun scheduleHeartbeat(intervalSeconds: Long, onHeartbeat: () -> Unit) {
|
||||||
|
onHeartbeatCallback = onHeartbeat
|
||||||
|
|
||||||
|
// Register the broadcast receiver
|
||||||
|
heartbeatReceiver = HeartbeatReceiver {
|
||||||
|
Log.d(TAG, "Heartbeat fired")
|
||||||
|
onHeartbeatCallback?.invoke()
|
||||||
|
}
|
||||||
|
val intentFilter = IntentFilter(ACTION_HEARTBEAT)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
context.registerReceiver(heartbeatReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
context.registerReceiver(heartbeatReceiver, intentFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule the alarm
|
||||||
|
val intent = Intent(ACTION_HEARTBEAT)
|
||||||
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
REQUEST_CODE,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val intervalMillis = intervalSeconds * 1000
|
||||||
|
val triggerAtMillis = SystemClock.elapsedRealtime() + intervalMillis
|
||||||
|
|
||||||
|
alarmManager.setRepeating(
|
||||||
|
AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
||||||
|
triggerAtMillis,
|
||||||
|
intervalMillis,
|
||||||
|
pendingIntent
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.d(TAG, "Heartbeat scheduled every ${intervalSeconds}s")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelHeartbeat() {
|
||||||
|
// Cancel the alarm
|
||||||
|
val intent = Intent(ACTION_HEARTBEAT)
|
||||||
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
REQUEST_CODE,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
alarmManager.cancel(pendingIntent)
|
||||||
|
|
||||||
|
// Unregister the receiver
|
||||||
|
heartbeatReceiver?.let {
|
||||||
|
try {
|
||||||
|
context.unregisterReceiver(it)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
Log.w(TAG, "HeartbeatReceiver was not registered", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
heartbeatReceiver = null
|
||||||
|
onHeartbeatCallback = null
|
||||||
|
|
||||||
|
Log.d(TAG, "Heartbeat cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
class HeartbeatReceiver(private val onHeartbeat: () -> Unit) : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action == ACTION_HEARTBEAT) {
|
||||||
|
onHeartbeat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "HeartbeatScheduler"
|
||||||
|
private const val ACTION_HEARTBEAT = "com.traccar.traccar_client.HEARTBEAT"
|
||||||
|
private const val REQUEST_CODE = 1001
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build and verify no compile errors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd android && ./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
Expected: BUILD SUCCESSFUL
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test manually**
|
||||||
|
|
||||||
|
1. Set Heartbeat to 60 seconds in Settings
|
||||||
|
2. Start tracking
|
||||||
|
3. Wait 60-90 seconds
|
||||||
|
4. Check Status/Logs for "HEARTBEAT" entries
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add android/app/src/main/kotlin/com/traccar/traccar_client/location/HeartbeatScheduler.kt
|
||||||
|
git commit -m "fix: replace WorkManager with AlarmManager for heartbeat
|
||||||
|
|
||||||
|
WorkManager runs workers in a separate process where the static
|
||||||
|
callback is always null. AlarmManager with a BroadcastReceiver
|
||||||
|
runs in the same process as the service, so the callback works."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Add Log Cleanup to Prevent Unbounded Growth
|
||||||
|
|
||||||
|
**Problem:** Event logs accumulate indefinitely with no cleanup.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add log cleanup in startTracking()**
|
||||||
|
|
||||||
|
Edit `android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt`, add after line 134 (`prefs.edit().putBoolean("tracking_active", true).apply()`):
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Cleanup old logs (keep last 24 hours)
|
||||||
|
serviceScope.launch {
|
||||||
|
val cutoffTime = System.currentTimeMillis() - (24 * 60 * 60 * 1000)
|
||||||
|
database.eventLogDao().deleteOlderThan(cutoffTime)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt
|
||||||
|
git commit -m "fix: cleanup old event logs on tracking start
|
||||||
|
|
||||||
|
Deletes logs older than 24 hours when tracking starts to prevent
|
||||||
|
unbounded database growth."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Final Integration Test
|
||||||
|
|
||||||
|
**Files:** None (manual testing)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Clean build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
flutter clean && flutter pub get && flutter build apk --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Install and test full flow**
|
||||||
|
|
||||||
|
1. Install the app on device
|
||||||
|
2. Configure your server URL and Device ID in Settings
|
||||||
|
3. Save Settings
|
||||||
|
4. Toggle tracking ON
|
||||||
|
5. Tap "Send Location" button
|
||||||
|
6. Check Status/Logs screen:
|
||||||
|
- Should see "INFO: Tracking started"
|
||||||
|
- Should see "LOCATION: Lat: X, Lon: Y"
|
||||||
|
- Should see "SYNC: Location sent to server"
|
||||||
|
7. Check your Traccar server - device should be ONLINE with location
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test heartbeat**
|
||||||
|
|
||||||
|
1. Wait 60+ seconds (default heartbeat interval)
|
||||||
|
2. Check Status/Logs for "HEARTBEAT" entries
|
||||||
|
3. Should see another "SYNC" after heartbeat
|
||||||
|
|
||||||
|
- [ ] **Step 4: Test offline buffering**
|
||||||
|
|
||||||
|
1. Enable airplane mode
|
||||||
|
2. Wait for a location update (should see "ERROR" in logs)
|
||||||
|
3. Disable airplane mode
|
||||||
|
4. Should see "NETWORK_CHANGE: Network: online"
|
||||||
|
5. Should see "SYNC: Synced X buffered locations"
|
||||||
|
|
||||||
|
- [ ] **Step 5: Create final commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "test: verify all bug fixes working together"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Fixes
|
||||||
|
|
||||||
|
| Bug | File(s) | Fix |
|
||||||
|
|-----|---------|-----|
|
||||||
|
| Config not synced to native | `main_screen.dart`, `location_bridge.dart` | Send config via MethodChannel before starting |
|
||||||
|
| ConnectivityReceiver not registered | `LocationTrackingService.kt` | Register in onCreate(), unregister in onDestroy() |
|
||||||
|
| Number fields not saving | `settings_screen.dart` | Use TextEditingController instead of initialValue |
|
||||||
|
| Memory leak - stream subscription | `main_screen.dart` | Store subscription, cancel in dispose() |
|
||||||
|
| Heartbeat never fires | `HeartbeatScheduler.kt` | Replace WorkManager with AlarmManager |
|
||||||
|
| Logs never cleaned up | `LocationTrackingService.kt` | Delete logs > 24h old on tracking start |
|
||||||
|
|
@ -12,22 +12,29 @@ class LocationBridge {
|
||||||
|
|
||||||
static Stream<Map<String, dynamic>>? _locationStream;
|
static Stream<Map<String, dynamic>>? _locationStream;
|
||||||
|
|
||||||
static Future<bool> startTracking() async {
|
static Future<bool> startTracking({Map<String, dynamic>? config}) async {
|
||||||
|
debugPrint('LocationBridge.startTracking called with config: $config');
|
||||||
try {
|
try {
|
||||||
|
if (config != null) {
|
||||||
|
await _methodChannel.invokeMethod('updateConfig', config);
|
||||||
|
}
|
||||||
final result = await _methodChannel.invokeMethod<bool>('startTracking');
|
final result = await _methodChannel.invokeMethod<bool>('startTracking');
|
||||||
|
debugPrint('LocationBridge.startTracking result: $result');
|
||||||
return result ?? false;
|
return result ?? false;
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
debugPrint('Failed to start tracking: ${e.message}');
|
debugPrint('LocationBridge.startTracking failed: ${e.message}');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> stopTracking() async {
|
static Future<bool> stopTracking() async {
|
||||||
|
debugPrint('LocationBridge.stopTracking called');
|
||||||
try {
|
try {
|
||||||
final result = await _methodChannel.invokeMethod<bool>('stopTracking');
|
final result = await _methodChannel.invokeMethod<bool>('stopTracking');
|
||||||
|
debugPrint('LocationBridge.stopTracking result: $result');
|
||||||
return result ?? false;
|
return result ?? false;
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
debugPrint('Failed to stop tracking: ${e.message}');
|
debugPrint('LocationBridge.stopTracking failed: ${e.message}');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -46,11 +53,13 @@ class LocationBridge {
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>?> getStatus() async {
|
static Future<Map<String, dynamic>?> getStatus() async {
|
||||||
|
debugPrint('LocationBridge.getStatus called');
|
||||||
try {
|
try {
|
||||||
final result = await _methodChannel.invokeMethod<Map>('getStatus');
|
final result = await _methodChannel.invokeMethod<Map>('getStatus');
|
||||||
|
debugPrint('LocationBridge.getStatus result: $result');
|
||||||
return result?.cast<String, dynamic>();
|
return result?.cast<String, dynamic>();
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
debugPrint('Failed to get status: ${e.message}');
|
debugPrint('LocationBridge.getStatus failed: ${e.message}');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -71,4 +80,15 @@ class LocationBridge {
|
||||||
);
|
);
|
||||||
return _locationStream!;
|
return _locationStream!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> getLogs({int limit = 100}) async {
|
||||||
|
try {
|
||||||
|
final result = await _methodChannel.invokeMethod<List>('getLogs', limit);
|
||||||
|
return result?.map((e) => Map<String, dynamic>.from(e as Map)).toList() ??
|
||||||
|
[];
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('Failed to get logs: ${e.message}');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:traccar_client/bridge/location_bridge.dart';
|
import 'package:traccar_client/bridge/location_bridge.dart';
|
||||||
|
import 'package:traccar_client/preferences.dart';
|
||||||
import 'package:traccar_client/settings_screen.dart';
|
import 'package:traccar_client/settings_screen.dart';
|
||||||
import 'package:traccar_client/status_screen.dart';
|
import 'package:traccar_client/status_screen.dart';
|
||||||
|
|
||||||
|
|
@ -11,6 +14,7 @@ class MainScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MainScreenState extends State<MainScreen> {
|
class _MainScreenState extends State<MainScreen> {
|
||||||
|
StreamSubscription<Map<String, dynamic>>? _locationSubscription;
|
||||||
bool _isTracking = false;
|
bool _isTracking = false;
|
||||||
String _lastLat = '--';
|
String _lastLat = '--';
|
||||||
String _lastLon = '--';
|
String _lastLon = '--';
|
||||||
|
|
@ -23,6 +27,12 @@ class _MainScreenState extends State<MainScreen> {
|
||||||
_initLocationStream();
|
_initLocationStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_locationSubscription?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _initLocationStream() async {
|
Future<void> _initLocationStream() async {
|
||||||
final status = await LocationBridge.getStatus();
|
final status = await LocationBridge.getStatus();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
@ -49,7 +59,7 @@ class _MainScreenState extends State<MainScreen> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
LocationBridge.locationUpdates.listen((location) {
|
_locationSubscription = LocationBridge.locationUpdates.listen((location) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_lastLat = location['latitude']?.toStringAsFixed(4) ?? '--';
|
_lastLat = location['latitude']?.toStringAsFixed(4) ?? '--';
|
||||||
|
|
@ -72,17 +82,39 @@ class _MainScreenState extends State<MainScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleTracking() async {
|
Future<void> _toggleTracking() async {
|
||||||
if (_isTracking) {
|
final previousState = _isTracking;
|
||||||
await LocationBridge.stopTracking();
|
final newState = !_isTracking;
|
||||||
} else {
|
|
||||||
await LocationBridge.startTracking();
|
|
||||||
}
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
final status = await LocationBridge.getStatus();
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isTracking = status?['isTracking'] == true;
|
_isTracking = newState;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool success;
|
||||||
|
if (newState) {
|
||||||
|
success = await LocationBridge.startTracking(config: {
|
||||||
|
'serverUrl': Preferences.serverUrl,
|
||||||
|
'deviceId': Preferences.deviceId,
|
||||||
|
'password': Preferences.password,
|
||||||
|
'accuracy': Preferences.accuracy,
|
||||||
|
'distanceFilter': Preferences.distanceFilter,
|
||||||
|
'interval': Preferences.interval,
|
||||||
|
'heartbeat': Preferences.heartbeat,
|
||||||
|
'offlineBuffer': Preferences.offlineBuffer,
|
||||||
|
'stopDetection': Preferences.stopDetection,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
success = await LocationBridge.stopTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isTracking = previousState;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(previousState ? 'Failed to stop tracking' : 'Failed to start tracking'),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,9 +201,10 @@ class _MainScreenState extends State<MainScreen> {
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
success
|
success
|
||||||
? 'Location reported'
|
? 'Location sent to server'
|
||||||
: 'Failed to report location',
|
: 'Failed - check logs',
|
||||||
),
|
),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ class SettingsScreen extends StatefulWidget {
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
late TextEditingController _serverUrlController;
|
late TextEditingController _serverUrlController;
|
||||||
late TextEditingController _deviceIdController;
|
late TextEditingController _deviceIdController;
|
||||||
|
late TextEditingController _distanceFilterController;
|
||||||
|
late TextEditingController _intervalController;
|
||||||
|
late TextEditingController _heartbeatController;
|
||||||
late int _accuracy;
|
late int _accuracy;
|
||||||
late int _distanceFilter;
|
|
||||||
late int _interval;
|
|
||||||
late int _heartbeat;
|
|
||||||
late bool _offlineBuffer;
|
late bool _offlineBuffer;
|
||||||
late bool _stopDetection;
|
late bool _stopDetection;
|
||||||
|
|
||||||
|
|
@ -24,10 +24,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
super.initState();
|
super.initState();
|
||||||
_serverUrlController = TextEditingController(text: Preferences.serverUrl);
|
_serverUrlController = TextEditingController(text: Preferences.serverUrl);
|
||||||
_deviceIdController = TextEditingController(text: Preferences.deviceId);
|
_deviceIdController = TextEditingController(text: Preferences.deviceId);
|
||||||
|
_distanceFilterController = TextEditingController(text: Preferences.distanceFilter.toString());
|
||||||
|
_intervalController = TextEditingController(text: Preferences.interval.toString());
|
||||||
|
_heartbeatController = TextEditingController(text: Preferences.heartbeat.toString());
|
||||||
_accuracy = Preferences.accuracy;
|
_accuracy = Preferences.accuracy;
|
||||||
_distanceFilter = Preferences.distanceFilter;
|
|
||||||
_interval = Preferences.interval;
|
|
||||||
_heartbeat = Preferences.heartbeat;
|
|
||||||
_offlineBuffer = Preferences.offlineBuffer;
|
_offlineBuffer = Preferences.offlineBuffer;
|
||||||
_stopDetection = Preferences.stopDetection;
|
_stopDetection = Preferences.stopDetection;
|
||||||
}
|
}
|
||||||
|
|
@ -36,16 +36,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_serverUrlController.dispose();
|
_serverUrlController.dispose();
|
||||||
_deviceIdController.dispose();
|
_deviceIdController.dispose();
|
||||||
|
_distanceFilterController.dispose();
|
||||||
|
_intervalController.dispose();
|
||||||
|
_heartbeatController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveSettings() async {
|
Future<void> _saveSettings() async {
|
||||||
|
final distanceFilter = int.tryParse(_distanceFilterController.text) ?? 75;
|
||||||
|
final interval = int.tryParse(_intervalController.text) ?? 300;
|
||||||
|
final heartbeat = int.tryParse(_heartbeatController.text) ?? 60;
|
||||||
|
|
||||||
await Preferences.setServerUrl(_serverUrlController.text);
|
await Preferences.setServerUrl(_serverUrlController.text);
|
||||||
await Preferences.setDeviceId(_deviceIdController.text);
|
await Preferences.setDeviceId(_deviceIdController.text);
|
||||||
await Preferences.setAccuracy(_accuracy);
|
await Preferences.setAccuracy(_accuracy);
|
||||||
await Preferences.setDistanceFilter(_distanceFilter);
|
await Preferences.setDistanceFilter(distanceFilter);
|
||||||
await Preferences.setInterval(_interval);
|
await Preferences.setInterval(interval);
|
||||||
await Preferences.setHeartbeat(_heartbeat);
|
await Preferences.setHeartbeat(heartbeat);
|
||||||
await Preferences.setOfflineBuffer(_offlineBuffer);
|
await Preferences.setOfflineBuffer(_offlineBuffer);
|
||||||
await Preferences.setStopDetection(_stopDetection);
|
await Preferences.setStopDetection(_stopDetection);
|
||||||
|
|
||||||
|
|
@ -53,9 +60,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
'serverUrl': _serverUrlController.text,
|
'serverUrl': _serverUrlController.text,
|
||||||
'deviceId': _deviceIdController.text,
|
'deviceId': _deviceIdController.text,
|
||||||
'accuracy': _accuracy,
|
'accuracy': _accuracy,
|
||||||
'distanceFilter': _distanceFilter,
|
'distanceFilter': distanceFilter,
|
||||||
'interval': _interval,
|
'interval': interval,
|
||||||
'heartbeat': _heartbeat,
|
'heartbeat': heartbeat,
|
||||||
'offlineBuffer': _offlineBuffer,
|
'offlineBuffer': _offlineBuffer,
|
||||||
'stopDetection': _stopDetection,
|
'stopDetection': _stopDetection,
|
||||||
});
|
});
|
||||||
|
|
@ -80,9 +87,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildSectionHeader('Location'),
|
_buildSectionHeader('Location'),
|
||||||
_buildAccuracyDropdown(),
|
_buildAccuracyDropdown(),
|
||||||
_buildNumberField('Distance Filter (m)', _distanceFilter, 0, 1000),
|
_buildNumberField('Distance Filter (m)', _distanceFilterController, 0, 1000),
|
||||||
_buildNumberField('Update Interval (s)', _interval, 30, 3600),
|
_buildNumberField('Update Interval (s)', _intervalController, 30, 3600),
|
||||||
_buildNumberField('Heartbeat (s)', _heartbeat, 60, 3600),
|
_buildNumberField('Heartbeat (s)', _heartbeatController, 60, 3600),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildSectionHeader('Advanced'),
|
_buildSectionHeader('Advanced'),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
|
|
@ -146,30 +153,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNumberField(String label, int value, int min, int max) {
|
Widget _buildNumberField(String label, TextEditingController controller, int min, int max) {
|
||||||
return TextFormField(
|
return TextField(
|
||||||
initialValue: value.toString(),
|
controller: controller,
|
||||||
decoration: InputDecoration(labelText: label),
|
decoration: InputDecoration(labelText: label),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
validator: (v) {
|
|
||||||
final parsed = int.tryParse(v ?? '');
|
|
||||||
if (parsed == null || parsed < min || parsed > max) {
|
|
||||||
return 'Must be between $min and $max';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
onSaved: (v) {
|
|
||||||
final parsed = int.tryParse(v ?? '');
|
|
||||||
if (parsed != null) {
|
|
||||||
if (label.contains('Distance')) {
|
|
||||||
_distanceFilter = parsed;
|
|
||||||
} else if (label.contains('Interval')) {
|
|
||||||
_interval = parsed;
|
|
||||||
} else if (label.contains('Heartbeat')) {
|
|
||||||
_heartbeat = parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,105 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'bridge/location_bridge.dart';
|
||||||
|
|
||||||
class StatusScreen extends StatelessWidget {
|
class StatusScreen extends StatefulWidget {
|
||||||
const StatusScreen({super.key});
|
const StatusScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatusScreen> createState() => _StatusScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusScreenState extends State<StatusScreen> {
|
||||||
|
List<Map<String, dynamic>> _logs = [];
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadLogs() async {
|
||||||
|
final logs = await LocationBridge.getLogs();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_logs = logs.reversed.toList();
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTimestamp(int timestamp) {
|
||||||
|
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||||
|
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getLogColor(String eventType) {
|
||||||
|
switch (eventType) {
|
||||||
|
case 'ERROR':
|
||||||
|
return Colors.red;
|
||||||
|
case 'WARNING':
|
||||||
|
return Colors.orange;
|
||||||
|
case 'LOCATION':
|
||||||
|
return Colors.green;
|
||||||
|
case 'SYNC':
|
||||||
|
return Colors.cyan;
|
||||||
|
case 'HEARTBEAT':
|
||||||
|
return Colors.yellow;
|
||||||
|
case 'FILTERED':
|
||||||
|
return Colors.grey;
|
||||||
|
default:
|
||||||
|
return Colors.white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTerminalLine(Map<String, dynamic> log) {
|
||||||
|
final time = _formatTimestamp(log['timestamp'] as int? ?? 0);
|
||||||
|
final type = log['eventType'] as String? ?? 'UNKNOWN';
|
||||||
|
final msg = log['message'] as String? ?? '';
|
||||||
|
final color = _getLogColor(type);
|
||||||
|
|
||||||
|
return Text(
|
||||||
|
'[$time] $type: $msg',
|
||||||
|
style: TextStyle(fontFamily: 'monospace', fontSize: 12, color: color),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Event Log')),
|
appBar: AppBar(
|
||||||
body: ListView(
|
title: Text('Event Log'),
|
||||||
padding: const EdgeInsets.all(16),
|
actions: [IconButton(icon: Icon(Icons.refresh), onPressed: _loadLogs)],
|
||||||
children: [
|
|
||||||
Card(
|
|
||||||
child: ListTile(
|
|
||||||
leading: const Icon(Icons.info, color: Colors.blue),
|
|
||||||
title: const Text('Status Screen'),
|
|
||||||
subtitle: const Text('Event logs will be displayed here'),
|
|
||||||
),
|
),
|
||||||
|
body: Container(
|
||||||
|
color: Colors.black,
|
||||||
|
child: _logs.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
'No logs yet. Start tracking to see logs.',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: EdgeInsets.all(8),
|
||||||
|
itemCount: _logs.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final log = _logs[index];
|
||||||
|
return _buildTerminalLine(log);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildPlaceholderEvent('LOCATION', '37.7749', '-122.4194'),
|
|
||||||
_buildPlaceholderEvent('SYNC', 'Location sent to server', ''),
|
|
||||||
_buildPlaceholderEvent('HEARTBEAT', 'Heartbeat fired', ''),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPlaceholderEvent(String type, String detail, String extra) {
|
|
||||||
return Card(
|
|
||||||
child: ListTile(
|
|
||||||
leading: Icon(_getIconForType(type), color: _getColorForType(type)),
|
|
||||||
title: Text(type),
|
|
||||||
subtitle: Text('$detail $extra'.trim()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _getIconForType(String type) {
|
|
||||||
switch (type) {
|
|
||||||
case 'LOCATION':
|
|
||||||
return Icons.location_on;
|
|
||||||
case 'SYNC':
|
|
||||||
return Icons.cloud_done;
|
|
||||||
case 'HEARTBEAT':
|
|
||||||
return Icons.favorite;
|
|
||||||
case 'FILTERED':
|
|
||||||
return Icons.filter_alt;
|
|
||||||
case 'ERROR':
|
|
||||||
return Icons.error;
|
|
||||||
default:
|
|
||||||
return Icons.info;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Color _getColorForType(String type) {
|
|
||||||
switch (type) {
|
|
||||||
case 'LOCATION':
|
|
||||||
return Colors.green;
|
|
||||||
case 'SYNC':
|
|
||||||
return Colors.blue;
|
|
||||||
case 'HEARTBEAT':
|
|
||||||
return Colors.orange;
|
|
||||||
case 'FILTERED':
|
|
||||||
return Colors.grey;
|
|
||||||
case 'ERROR':
|
|
||||||
return Colors.red;
|
|
||||||
default:
|
|
||||||
return Colors.grey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue