diff --git a/BUG_REPORT.md b/BUG_REPORT.md new file mode 100644 index 0000000..a505294 --- /dev/null +++ b/BUG_REPORT.md @@ -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 _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 _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. \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/traccar/traccar_client/BridgeModule.kt b/android/app/src/main/kotlin/com/traccar/traccar_client/BridgeModule.kt index 2005106..20b29af 100644 --- a/android/app/src/main/kotlin/com/traccar/traccar_client/BridgeModule.kt +++ b/android/app/src/main/kotlin/com/traccar/traccar_client/BridgeModule.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.SharedPreferences import android.os.Handler import android.os.Looper +import android.util.Log import com.traccar.traccar_client.service.LocationTrackingService import com.traccar.traccar_client.storage.AppDatabase import io.flutter.embedding.engine.plugins.FlutterPlugin @@ -25,6 +26,13 @@ class BridgeModule : FlutterPlugin, MethodChannel.MethodCallHandler { context = binding.applicationContext 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.setMethodCallHandler(this) @@ -48,17 +56,20 @@ class BridgeModule : FlutterPlugin, MethodChannel.MethodCallHandler { companion object { var eventSink: EventChannel.EventSink? = null + private const val TAG = "BridgeModule" } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "startTracking" -> { - startTrackingService() - result.success(true) + val success = startTrackingService() + Log.d(TAG, "startTracking result: $success") + result.success(success) } "stopTracking" -> { - stopTrackingService() - result.success(true) + val success = stopTrackingService() + Log.d(TAG, "stopTracking result: $success") + result.success(success) } "updateConfig" -> { val config = call.arguments as? Map<*, *> @@ -80,21 +91,42 @@ class BridgeModule : FlutterPlugin, MethodChannel.MethodCallHandler { } } - private fun startTrackingService() { - val intent = android.content.Intent(context, LocationTrackingService::class.java).apply { - action = LocationTrackingService.ACTION_START + 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 { + action = intentAction + } + Log.d(TAG, "startTrackingService: intent action=$intentAction") + context?.startForegroundService(intent) + Log.d(TAG, "startTrackingService: called startForegroundService") + true + } catch (e: Exception) { + Log.e(TAG, "startTrackingService failed", e) + false } - context?.startForegroundService(intent) } - private fun stopTrackingService() { - val intent = android.content.Intent(context, LocationTrackingService::class.java).apply { - action = LocationTrackingService.ACTION_STOP + 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 { + action = intentAction + } + Log.d(TAG, "stopTrackingService: intent action=$intentAction") + context?.startService(intent) + Log.d(TAG, "stopTrackingService: called startService") + true + } catch (e: Exception) { + Log.e(TAG, "stopTrackingService failed", e) + false } - context?.startService(intent) } private fun updateTrackingConfig(config: Map<*, *>?) { + Log.d(TAG, "updateTrackingConfig: config=$config") if (config == null) return prefs.edit().apply { 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) } apply() } + Log.d(TAG, "updateTrackingConfig: saved to prefs") } private fun getTrackingStatus(): Map { - return mapOf( + val result = mapOf( "isTracking" to prefs.getBoolean("tracking_active", false), "lastLatitude" to prefs.getFloat("last_latitude", 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(), "lastTimestamp" to prefs.getLong("last_timestamp", 0L) ) + Log.d(TAG, "getTrackingStatus: isTracking=${result["isTracking"]}") + return result } private fun reportLocationNow() { diff --git a/android/app/src/main/kotlin/com/traccar/traccar_client/location/HeartbeatScheduler.kt b/android/app/src/main/kotlin/com/traccar/traccar_client/location/HeartbeatScheduler.kt index 579b4e4..8d531e9 100644 --- a/android/app/src/main/kotlin/com/traccar/traccar_client/location/HeartbeatScheduler.kt +++ b/android/app/src/main/kotlin/com/traccar/traccar_client/location/HeartbeatScheduler.kt @@ -1,56 +1,90 @@ package com.traccar.traccar_client.location +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver import android.content.Context -import androidx.work.Constraints -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.Worker -import androidx.work.WorkerParameters -import java.util.concurrent.TimeUnit +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 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) { onHeartbeatCallback = onHeartbeat - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.NOT_REQUIRED) - .build() - val heartbeatRequest = PeriodicWorkRequestBuilder( - intervalSeconds, TimeUnit.SECONDS - ) - .setConstraints(constraints) - .addTag(HEARTBEAT_WORK_TAG) - .build() + 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) + } - workManager.enqueueUniquePeriodicWork( - HEARTBEAT_WORK_NAME, - ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, - heartbeatRequest + 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() { - 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( - context: Context, - params: WorkerParameters - ) : Worker(context, params) { - override fun doWork(): Result { - onHeartbeatCallback?.invoke() - return Result.success() + class HeartbeatReceiver(private val onHeartbeat: () -> Unit) : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == ACTION_HEARTBEAT) { + onHeartbeat() + } } } companion object { - var onHeartbeatCallback: (() -> Unit)? = null - const val HEARTBEAT_WORK_TAG = "traccar_heartbeat" - const val HEARTBEAT_WORK_NAME = "traccar_heartbeat_work" + private const val TAG = "HeartbeatScheduler" + private const val ACTION_HEARTBEAT = "com.traccar.traccar_client.HEARTBEAT" + private const val REQUEST_CODE = 1001 } } diff --git a/android/app/src/main/kotlin/com/traccar/traccar_client/network/TraccarHttpClient.kt b/android/app/src/main/kotlin/com/traccar/traccar_client/network/TraccarHttpClient.kt index 22eac76..5cc689b 100644 --- a/android/app/src/main/kotlin/com/traccar/traccar_client/network/TraccarHttpClient.kt +++ b/android/app/src/main/kotlin/com/traccar/traccar_client/network/TraccarHttpClient.kt @@ -4,8 +4,10 @@ import android.util.Base64 import com.traccar.traccar_client.model.Location import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.MediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody import java.net.URLEncoder import java.util.concurrent.TimeUnit @@ -67,7 +69,8 @@ class TraccarHttpClient { } 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()) { val credentials = "${config.deviceId}:${config.password}" diff --git a/android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt b/android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt index e9cb0b1..2866535 100644 --- a/android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt +++ b/android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt @@ -7,7 +7,9 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.SharedPreferences +import android.net.ConnectivityManager import android.location.Location import android.os.Binder import android.os.Build @@ -33,10 +35,13 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import android.util.Log + class LocationTrackingService : Service() { private val binder = LocalBinder() private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val TAG = "LocationTrackingService" private lateinit var fusedLocationProvider: FusedLocationProvider private lateinit var distanceFilterProcessor: DistanceFilterProcessor @@ -71,15 +76,31 @@ class LocationTrackingService : Service() { if (online) syncBufferedLocations() } + val intentFilter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) + registerReceiver(connectivityReceiver, intentFilter) + + isNetworkAvailable = ConnectivityReceiver.isNetworkAvailable(this) + createNotificationChannel() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "onStartCommand: action=${intent?.action}") when (intent?.action) { - ACTION_START -> startTracking() - ACTION_STOP -> stopTracking() - ACTION_REPORT -> reportLocationNow() + ACTION_START -> { + Log.d(TAG, "onStartCommand: calling startTracking") + 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 } @@ -87,13 +108,23 @@ class LocationTrackingService : Service() { override fun onDestroy() { super.onDestroy() + try { + unregisterReceiver(connectivityReceiver) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "ConnectivityReceiver was not registered", e) + } stopTracking() serviceScope.cancel() } 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( serverUrl = prefs.getString("server_url", "https://demo.traccar.org") ?: "https://demo.traccar.org", deviceId = prefs.getString("device_id", "") ?: "", @@ -113,6 +144,12 @@ class LocationTrackingService : Service() { isTracking = true distanceFilterProcessor.reset() 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( interval = config.interval * 1000L, @@ -129,13 +166,19 @@ class LocationTrackingService : Service() { } logEvent("INFO", "Tracking started") + Log.d(TAG, "startTracking: completed") } fun stopTracking() { - if (!isTracking) return + Log.d(TAG, "stopTracking: isTracking=$isTracking") + if (!isTracking) { + Log.d(TAG, "stopTracking: not tracking, returning") + return + } isTracking = false prefs.edit().putBoolean("tracking_active", false).apply() + Log.d(TAG, "stopTracking: set tracking_active=false") fusedLocationProvider.stopLocationUpdates() heartbeatScheduler.cancelHeartbeat() releaseWakeLock() @@ -144,6 +187,7 @@ class LocationTrackingService : Service() { stopSelf() logEvent("INFO", "Tracking stopped") + Log.d(TAG, "stopTracking: completed") } fun reportLocationNow() { diff --git a/docs/superpowers/plans/2026-04-30-traccar-client-bugfixes.md b/docs/superpowers/plans/2026-04-30-traccar-client-bugfixes.md new file mode 100644 index 0000000..ef1ae35 --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-traccar-client-bugfixes.md @@ -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 startTracking({Map? 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('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 _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 { + 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 _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 createState() => _MainScreenState(); +} + +class _MainScreenState extends State { + bool _isTracking = false; + String _lastLat = '--'; + String _lastLon = '--'; + String _lastSpeed = '--'; + String _lastTime = '--'; + StreamSubscription>? _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 | diff --git a/lib/bridge/location_bridge.dart b/lib/bridge/location_bridge.dart index d8376e7..508f878 100644 --- a/lib/bridge/location_bridge.dart +++ b/lib/bridge/location_bridge.dart @@ -12,22 +12,29 @@ class LocationBridge { static Stream>? _locationStream; - static Future startTracking() async { + static Future startTracking({Map? config}) async { + debugPrint('LocationBridge.startTracking called with config: $config'); try { + if (config != null) { + await _methodChannel.invokeMethod('updateConfig', config); + } final result = await _methodChannel.invokeMethod('startTracking'); + debugPrint('LocationBridge.startTracking result: $result'); return result ?? false; } on PlatformException catch (e) { - debugPrint('Failed to start tracking: ${e.message}'); + debugPrint('LocationBridge.startTracking failed: ${e.message}'); return false; } } static Future stopTracking() async { + debugPrint('LocationBridge.stopTracking called'); try { final result = await _methodChannel.invokeMethod('stopTracking'); + debugPrint('LocationBridge.stopTracking result: $result'); return result ?? false; } on PlatformException catch (e) { - debugPrint('Failed to stop tracking: ${e.message}'); + debugPrint('LocationBridge.stopTracking failed: ${e.message}'); return false; } } @@ -46,11 +53,13 @@ class LocationBridge { } static Future?> getStatus() async { + debugPrint('LocationBridge.getStatus called'); try { final result = await _methodChannel.invokeMethod('getStatus'); + debugPrint('LocationBridge.getStatus result: $result'); return result?.cast(); } on PlatformException catch (e) { - debugPrint('Failed to get status: ${e.message}'); + debugPrint('LocationBridge.getStatus failed: ${e.message}'); return null; } } @@ -71,4 +80,15 @@ class LocationBridge { ); return _locationStream!; } + + static Future>> getLogs({int limit = 100}) async { + try { + final result = await _methodChannel.invokeMethod('getLogs', limit); + return result?.map((e) => Map.from(e as Map)).toList() ?? + []; + } on PlatformException catch (e) { + debugPrint('Failed to get logs: ${e.message}'); + return []; + } + } } diff --git a/lib/main_screen.dart b/lib/main_screen.dart index 74af1d1..6aa5c1c 100644 --- a/lib/main_screen.dart +++ b/lib/main_screen.dart @@ -1,5 +1,8 @@ +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'; @@ -11,6 +14,7 @@ class MainScreen extends StatefulWidget { } class _MainScreenState extends State { + StreamSubscription>? _locationSubscription; bool _isTracking = false; String _lastLat = '--'; String _lastLon = '--'; @@ -23,6 +27,12 @@ class _MainScreenState extends State { _initLocationStream(); } + @override + void dispose() { + _locationSubscription?.cancel(); + super.dispose(); + } + Future _initLocationStream() async { final status = await LocationBridge.getStatus(); if (mounted) { @@ -49,7 +59,7 @@ class _MainScreenState extends State { }); } - LocationBridge.locationUpdates.listen((location) { + _locationSubscription = LocationBridge.locationUpdates.listen((location) { if (mounted) { setState(() { _lastLat = location['latitude']?.toStringAsFixed(4) ?? '--'; @@ -72,17 +82,39 @@ class _MainScreenState extends State { } Future _toggleTracking() async { - if (_isTracking) { - await LocationBridge.stopTracking(); - } else { - await LocationBridge.startTracking(); - } - await Future.delayed(const Duration(milliseconds: 500)); - final status = await LocationBridge.getStatus(); - if (mounted) { - setState(() { - _isTracking = status?['isTracking'] == true; + final previousState = _isTracking; + final newState = !_isTracking; + + setState(() { + _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 { SnackBar( content: Text( success - ? 'Location reported' - : 'Failed to report location', + ? 'Location sent to server' + : 'Failed - check logs', ), + duration: Duration(seconds: 2), ), ); } diff --git a/lib/settings_screen.dart b/lib/settings_screen.dart index fbfee47..e8da1a2 100644 --- a/lib/settings_screen.dart +++ b/lib/settings_screen.dart @@ -12,10 +12,10 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { late TextEditingController _serverUrlController; late TextEditingController _deviceIdController; + late TextEditingController _distanceFilterController; + late TextEditingController _intervalController; + late TextEditingController _heartbeatController; late int _accuracy; - late int _distanceFilter; - late int _interval; - late int _heartbeat; late bool _offlineBuffer; late bool _stopDetection; @@ -24,10 +24,10 @@ class _SettingsScreenState extends State { 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; - _distanceFilter = Preferences.distanceFilter; - _interval = Preferences.interval; - _heartbeat = Preferences.heartbeat; _offlineBuffer = Preferences.offlineBuffer; _stopDetection = Preferences.stopDetection; } @@ -36,16 +36,23 @@ class _SettingsScreenState extends State { void dispose() { _serverUrlController.dispose(); _deviceIdController.dispose(); + _distanceFilterController.dispose(); + _intervalController.dispose(); + _heartbeatController.dispose(); super.dispose(); } Future _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.setDeviceId(_deviceIdController.text); await Preferences.setAccuracy(_accuracy); - await Preferences.setDistanceFilter(_distanceFilter); - await Preferences.setInterval(_interval); - await Preferences.setHeartbeat(_heartbeat); + await Preferences.setDistanceFilter(distanceFilter); + await Preferences.setInterval(interval); + await Preferences.setHeartbeat(heartbeat); await Preferences.setOfflineBuffer(_offlineBuffer); await Preferences.setStopDetection(_stopDetection); @@ -53,9 +60,9 @@ class _SettingsScreenState extends State { 'serverUrl': _serverUrlController.text, 'deviceId': _deviceIdController.text, 'accuracy': _accuracy, - 'distanceFilter': _distanceFilter, - 'interval': _interval, - 'heartbeat': _heartbeat, + 'distanceFilter': distanceFilter, + 'interval': interval, + 'heartbeat': heartbeat, 'offlineBuffer': _offlineBuffer, 'stopDetection': _stopDetection, }); @@ -80,9 +87,9 @@ class _SettingsScreenState extends State { const SizedBox(height: 16), _buildSectionHeader('Location'), _buildAccuracyDropdown(), - _buildNumberField('Distance Filter (m)', _distanceFilter, 0, 1000), - _buildNumberField('Update Interval (s)', _interval, 30, 3600), - _buildNumberField('Heartbeat (s)', _heartbeat, 60, 3600), + _buildNumberField('Distance Filter (m)', _distanceFilterController, 0, 1000), + _buildNumberField('Update Interval (s)', _intervalController, 30, 3600), + _buildNumberField('Heartbeat (s)', _heartbeatController, 60, 3600), const SizedBox(height: 16), _buildSectionHeader('Advanced'), SwitchListTile( @@ -146,30 +153,11 @@ class _SettingsScreenState extends State { ); } - Widget _buildNumberField(String label, int value, int min, int max) { - return TextFormField( - initialValue: value.toString(), + Widget _buildNumberField(String label, TextEditingController controller, int min, int max) { + return TextField( + controller: controller, decoration: InputDecoration(labelText: label), 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; - } - } - }, ); } } diff --git a/lib/status_screen.dart b/lib/status_screen.dart index b701b67..5096bf9 100644 --- a/lib/status_screen.dart +++ b/lib/status_screen.dart @@ -1,72 +1,105 @@ import 'package:flutter/material.dart'; +import 'bridge/location_bridge.dart'; -class StatusScreen extends StatelessWidget { +class StatusScreen extends StatefulWidget { const StatusScreen({super.key}); + @override + State createState() => _StatusScreenState(); +} + +class _StatusScreenState extends State { + List> _logs = []; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _loadLogs(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + Future _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 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 Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Event Log')), - body: ListView( - padding: const EdgeInsets.all(16), - 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'), - ), - ), - const SizedBox(height: 16), - _buildPlaceholderEvent('LOCATION', '37.7749', '-122.4194'), - _buildPlaceholderEvent('SYNC', 'Location sent to server', ''), - _buildPlaceholderEvent('HEARTBEAT', 'Heartbeat fired', ''), - ], + appBar: AppBar( + title: Text('Event Log'), + actions: [IconButton(icon: Icon(Icons.refresh), onPressed: _loadLogs)], + ), + 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); + }, + ), ), ); } - - 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; - } - } }