diff --git a/docs/superpowers/plans/2026-04-30-traccar-client-implementation.md b/docs/superpowers/plans/2026-04-30-traccar-client-implementation.md
new file mode 100644
index 0000000..af36ac7
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-30-traccar-client-implementation.md
@@ -0,0 +1,1979 @@
+# Traccar Client 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:** Build a reliable Android GPS tracking app using Flutter + Native Kotlin that reports to a self-hosted Traccar server.
+
+**Architecture:** Flutter UI layer connects to Kotlin Android service via Platform Channels. Native Android foreground service handles reliable background location tracking with Room-based offline buffering and WorkManager-based heartbeat scheduling.
+
+**Tech Stack:** Flutter, Kotlin Android, FusedLocationProviderClient (Google Play Services), Room (SQLite), WorkManager, OkHttp
+
+---
+
+## File Structure
+
+```
+traccar_client/
+├── lib/
+│ ├── main.dart
+│ ├── main_screen.dart
+│ ├── settings_screen.dart
+│ ├── status_screen.dart
+│ ├── preferences.dart
+│ └── bridge/
+│ └── location_bridge.dart
+├── android/app/src/main/
+│ ├── kotlin/com/traccar/traccar_client/
+│ │ ├── MainActivity.kt
+│ │ ├── BridgeModule.kt
+│ │ ├── service/
+│ │ │ └── LocationTrackingService.kt
+│ │ ├── location/
+│ │ │ ├── FusedLocationProvider.kt
+│ │ │ ├── DistanceFilterProcessor.kt
+│ │ │ └── HeartbeatScheduler.kt
+│ │ ├── storage/
+│ │ │ ├── AppDatabase.kt
+│ │ │ ├── LocationDao.kt
+│ │ │ └── EventLogDao.kt
+│ │ ├── network/
+│ │ │ ├── TraccarHttpClient.kt
+│ │ │ └── ConnectivityReceiver.kt
+│ │ └── model/
+│ │ └── Location.kt
+│ └── AndroidManifest.xml
+├── docs/superpowers/plans/this-file.md
+└── pubspec.yaml
+```
+
+---
+
+## Task 1: Configure Android Permissions and Dependencies
+
+**Files:**
+- Modify: `android/app/src/main/AndroidManifest.xml`
+- Modify: `android/app/build.gradle.kts`
+- Modify: `pubspec.yaml`
+
+- [ ] **Step 1: Update AndroidManifest.xml**
+
+Add permissions before `` closing tag:
+
+```xml
+
+
+
+
+
+
+
+
+```
+
+Add service declaration inside `` before ``:
+
+```xml
+
+```
+
+- [ ] **Step 2: Update pubspec.yaml dependencies**
+
+```yaml
+dependencies:
+ flutter:
+ sdk: flutter
+ shared_preferences: ^2.2.0
+ workmanager: ^0.5.0
+ sqflite: ^2.3.0
+ http: ^1.1.0
+ permission_handler: ^11.0.0
+ flutter_local_notifications: ^16.0.0
+```
+
+- [ ] **Step 3: Update android/app/build.gradle.kts**
+
+Add to `dependencies` block:
+
+```groovy
+implementation 'androidx.room:room-runtime:2.5.0'
+implementation 'androidx.room:room-ktx:2.5.0'
+implementation 'com.google.android.gms:play-services-location:21.0.1'
+implementation 'androidx.work:work-runtime:2.8.0'
+implementation 'androidx.appcompat:appcompat:1.6.1'
+implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
+kapt 'androidx.room:room-compiler:2.5.0'
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add android/app/src/main/AndroidManifest.xml android/app/build.gradle.kts pubspec.yaml
+git commit -m "chore: configure Android permissions and dependencies"
+```
+
+---
+
+## Task 2: Create Location Data Model
+
+**Files:**
+- Create: `android/app/src/main/kotlin/com/traccar/traccar_client/model/Location.kt`
+- Create: `android/app/src/main/kotlin/com/traccar/traccar_client/model/EventLogEntry.kt`
+
+- [ ] **Step 1: Create Location.kt**
+
+```kotlin
+package com.traccar.traccar_client.model
+
+data class Location(
+ val id: Long = 0,
+ val timestamp: Long,
+ val latitude: Double,
+ val longitude: Double,
+ val accuracy: Float?,
+ val speed: Float?,
+ val heading: Float?,
+ val altitude: Double?,
+ val isMoving: Boolean,
+ val synced: Boolean = false
+)
+```
+
+- [ ] **Step 2: Create EventLogEntry.kt**
+
+```kotlin
+package com.traccar.traccar_client.model
+
+data class EventLogEntry(
+ val id: Long = 0,
+ val timestamp: Long,
+ val eventType: String,
+ val message: String
+)
+
+enum class EventType {
+ LOCATION,
+ FILTERED,
+ SYNC,
+ HEARTBEAT,
+ NETWORK_CHANGE,
+ ERROR
+}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add android/app/src/main/kotlin/com/traccar/traccar_client/model/
+git commit -m "feat: add Location and EventLogEntry data models"
+```
+
+---
+
+## Task 3: Create Room Database and DAOs
+
+**Files:**
+- Create: `android/app/src/main/kotlin/com/traccar/traccar_client/storage/AppDatabase.kt`
+- Create: `android/app/src/main/kotlin/com/traccar/traccar_client/storage/LocationDao.kt`
+- Create: `android/app/src/main/kotlin/com/traccar/traccar_client/storage/EventLogDao.kt`
+- Create: `android/app/src/main/kotlin/com/traccar/traccar_client/storage/LocationEntity.kt`
+- Create: `android/app/src/main/kotlin/com/traccar/traccar_client/storage/EventLogEntity.kt`
+
+- [ ] **Step 1: Create LocationEntity.kt**
+
+```kotlin
+package com.traccar.traccar_client.storage
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "locations")
+data class LocationEntity(
+ @PrimaryKey(autoGenerate = true)
+ val id: Long = 0,
+ val timestamp: Long,
+ val latitude: Double,
+ val longitude: Double,
+ val accuracy: Float?,
+ val speed: Float?,
+ val heading: Float?,
+ val altitude: Double?,
+ val isMoving: Boolean,
+ val synced: Boolean = false
+)
+```
+
+- [ ] **Step 2: Create EventLogEntity.kt**
+
+```kotlin
+package com.traccar.traccar_client.storage
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "event_log")
+data class EventLogEntity(
+ @PrimaryKey(autoGenerate = true)
+ val id: Long = 0,
+ val timestamp: Long,
+ val eventType: String,
+ val message: String
+)
+```
+
+- [ ] **Step 3: Create LocationDao.kt**
+
+```kotlin
+package com.traccar.traccar_client.storage
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.Query
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface LocationDao {
+ @Insert
+ suspend fun insert(location: LocationEntity): Long
+
+ @Query("SELECT * FROM locations WHERE synced = 0 ORDER BY timestamp ASC")
+ suspend fun getUnsyncedLocations(): List
+
+ @Query("UPDATE locations SET synced = 1 WHERE id = :id")
+ suspend fun markAsSynced(id: Long)
+
+ @Query("UPDATE locations SET synced = 1 WHERE id IN (:ids)")
+ suspend fun markAllAsSynced(ids: List)
+
+ @Query("SELECT * FROM locations ORDER BY timestamp DESC LIMIT 1")
+ fun getLastLocation(): Flow
+
+ @Query("SELECT COUNT(*) FROM locations WHERE synced = 0")
+ suspend fun getUnsyncedCount(): Int
+
+ @Query("DELETE FROM locations WHERE synced = 1 AND timestamp < :beforeTimestamp")
+ suspend fun deleteSyncedOlderThan(beforeTimestamp: Long)
+}
+```
+
+- [ ] **Step 4: Create EventLogDao.kt**
+
+```kotlin
+package com.traccar.traccar_client.storage
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface EventLogDao {
+ @Insert
+ suspend fun insert(entry: EventLogEntity): Long
+
+ @Query("SELECT * FROM event_log ORDER BY timestamp DESC LIMIT 100")
+ fun getRecentEvents(): Flow>
+
+ @Query("SELECT * FROM event_log ORDER BY timestamp DESC LIMIT :limit")
+ suspend fun getRecentEventsList(limit: Int): List
+
+ @Query("DELETE FROM event_log WHERE timestamp < :beforeTimestamp")
+ suspend fun deleteOlderThan(beforeTimestamp: Long)
+}
+```
+
+- [ ] **Step 5: Create AppDatabase.kt**
+
+```kotlin
+package com.traccar.traccar_client.storage
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+
+@Database(
+ entities = [LocationEntity::class, EventLogEntity::class],
+ version = 1,
+ exportSchema = false
+)
+abstract class AppDatabase : RoomDatabase() {
+ abstract fun locationDao(): LocationDao
+ abstract fun eventLogDao(): EventLogDao
+
+ companion object {
+ @Volatile
+ private var INSTANCE: AppDatabase? = null
+
+ fun getInstance(context: Context): AppDatabase {
+ return INSTANCE ?: synchronized(this) {
+ val instance = Room.databaseBuilder(
+ context.applicationContext,
+ AppDatabase::class.java,
+ "traccar_client_db"
+ ).build()
+ INSTANCE = instance
+ instance
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add android/app/src/main/kotlin/com/traccar/traccar_client/storage/
+git commit -m "feat: add Room database with LocationDao and EventLogDao"
+```
+
+---
+
+## Task 4: Create FusedLocationProvider
+
+**Files:**
+- Create: `android/app/src/main/kotlin/com/traccar/traccar_client/location/FusedLocationProvider.kt`
+
+- [ ] **Step 1: Create FusedLocationProvider.kt**
+
+```kotlin
+package com.traccar.traccar_client.location
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.location.Location
+import android.os.Looper
+import com.google.android.gms.location.FusedLocationProviderClient
+import com.google.android.gms.location.LocationCallback
+import com.google.android.gms.location.LocationRequest
+import com.google.android.gms.location.LocationResult
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.location.Priority
+
+class FusedLocationProvider(context: Context) {
+
+ private val fusedLocationClient: FusedLocationProviderClient =
+ LocationServices.getFusedLocationProviderClient(context)
+
+ private var locationCallback: LocationCallback? = null
+
+ @SuppressLint("MissingPermission")
+ fun startLocationUpdates(
+ interval: Long,
+ fastestInterval: Long,
+ accuracy: Int,
+ callback: (Location) -> Unit
+ ) {
+ val locationRequest = LocationRequest.Builder(interval)
+ .setMinUpdateIntervalMillis(fastestInterval)
+ .setPriority(mapAccuracy(accuracy))
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ result.lastLocation?.let { callback(it) }
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(
+ locationRequest,
+ locationCallback!!,
+ Looper.getMainLooper()
+ )
+ }
+
+ fun stopLocationUpdates() {
+ locationCallback?.let {
+ fusedLocationClient.removeLocationUpdates(it)
+ locationCallback = null
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ fun getLastLocation(callback: (Location?) -> Unit) {
+ fusedLocationClient.lastLocation
+ .addOnSuccessListener { location -> callback(location) }
+ .addOnFailureListener { callback(null) }
+ }
+
+ private fun mapAccuracy(accuracy: Int): Int {
+ return when (accuracy) {
+ 0 -> Priority.PRIORITY_HIGH_ACCURACY
+ 1 -> Priority.PRIORITY_HIGH_ACCURACY
+ 2 -> Priority.PRIORITY_BALANCED_POWER_ACCURACY
+ 3 -> Priority.PRIORITY_LOW_POWER
+ else -> Priority.PRIORITY_BALANCED_POWER_ACCURACY
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add android/app/src/main/kotlin/com/traccar/traccar_client/location/FusedLocationProvider.kt
+git commit -m "feat: add FusedLocationProvider for GPS location acquisition"
+```
+
+---
+
+## Task 5: Create DistanceFilterProcessor
+
+**Files:**
+- Create: `android/app/src/main/kotlin/com/traccar/traccar_client/location/DistanceFilterProcessor.kt`
+
+- [ ] **Step 1: Create DistanceFilterProcessor.kt**
+
+```kotlin
+package com.traccar.traccar_client.location
+
+import android.location.Location
+import kotlin.math.abs
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+data class FilterConfig(
+ val distanceFilter: Int = 75,
+ val intervalFilter: Int = 300,
+ val angleFilter: Int = 0
+)
+
+class DistanceFilterProcessor {
+ private var lastLatitude: Double? = null
+ private var lastLongitude: Double? = null
+ private var lastTimestamp: Long = 0
+ private var lastHeading: Float? = null
+
+ fun shouldAccept(location: Location, config: FilterConfig): Boolean {
+ val now = System.currentTimeMillis()
+
+ // Distance filter
+ if (config.distanceFilter > 0 && lastLatitude != null && lastLongitude != null) {
+ val distance = haversineDistance(lastLatitude!!, lastLongitude!!, location.latitude, location.longitude)
+ if (distance < config.distanceFilter) {
+ // Check interval as fallback
+ if (config.intervalFilter > 0) {
+ val elapsed = (now - lastTimestamp) / 1000
+ if (elapsed < config.intervalFilter) {
+ return false
+ }
+ } else {
+ return false
+ }
+ }
+ }
+
+ // Interval filter (when distance filter is 0)
+ if (config.distanceFilter == 0 && config.intervalFilter > 0) {
+ val elapsed = (now - lastTimestamp) / 1000
+ if (elapsed < config.intervalFilter && lastTimestamp > 0) {
+ return false
+ }
+ }
+
+ // Angle filter
+ if (config.angleFilter > 0 && lastHeading != null && location.hasBearing()) {
+ val headingDelta = abs(location.bearing - lastHeading!!)
+ // Handle wrap-around at 360 degrees
+ val normalizedDelta = if (headingDelta > 180) 360 - headingDelta else headingDelta
+ if (normalizedDelta < config.angleFilter && lastTimestamp > 0) {
+ return false
+ }
+ }
+
+ // Update last known values
+ lastLatitude = location.latitude
+ lastLongitude = location.longitude
+ lastTimestamp = now
+ if (location.hasBearing()) {
+ lastHeading = location.bearing
+ }
+
+ return true
+ }
+
+ fun reset() {
+ lastLatitude = null
+ lastLongitude = null
+ lastTimestamp = 0
+ lastHeading = null
+ }
+
+ companion object {
+ /**
+ * Calculate distance between two coordinates using Haversine formula
+ * @return distance in meters
+ */
+ fun haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val earthRadius = 6371000.0 // meters
+
+ val dLat = Math.toRadians(lat2 - lat1)
+ val dLon = Math.toRadians(lon2 - lon1)
+
+ val a = sin(dLat / 2) * sin(dLat / 2) +
+ cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
+ sin(dLon / 2) * sin(dLon / 2)
+
+ val c = 2 * atan2(sqrt(a), sqrt(1 - a))
+
+ return earthRadius * c
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add android/app/src/main/kotlin/com/traccar/traccar_client/location/DistanceFilterProcessor.kt
+git commit -m "feat: add DistanceFilterProcessor with Haversine formula"
+```
+
+---
+
+## Task 6: Create TraccarHttpClient
+
+**Files:**
+- Create: `android/app/src/main/kotlin/com/traccar/traccar_client/network/TraccarHttpClient.kt`
+
+- [ ] **Step 1: Create TraccarHttpClient.kt**
+
+```kotlin
+package com.traccar.traccar_client.network
+
+import android.util.Base64
+import com.traccar.traccar_client.model.Location
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.net.URLEncoder
+import java.util.concurrent.TimeUnit
+
+data class TraccarConfig(
+ val serverUrl: String,
+ val deviceId: String,
+ val password: String = ""
+)
+
+class TraccarHttpClient {
+
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .build()
+
+ suspend fun sendLocation(config: TraccarConfig, location: Location): Result = withContext(Dispatchers.IO) {
+ try {
+ val url = buildLocationUrl(config, location)
+ val request = buildRequest(config, url)
+
+ val response = client.newCall(request).execute()
+ if (response.isSuccessful) {
+ Result.success(Unit)
+ } else {
+ Result.failure(Exception("HTTP ${response.code}: ${response.message}"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun sendLocations(config: TraccarConfig, locations: List): Result = withContext(Dispatchers.IO) {
+ var successCount = 0
+ for (location in locations) {
+ val result = sendLocation(config, location)
+ if (result.isSuccess) {
+ successCount++
+ }
+ }
+ Result.success(successCount)
+ }
+
+ private fun buildLocationUrl(config: TraccarConfig, location: Location): String {
+ val baseUrl = config.serverUrl.trimEnd('/')
+ val timestamp = location.timestamp
+
+ return "$baseUrl/?" +
+ "id=${URLEncoder.encode(config.deviceId, "UTF-8")}" +
+ "&lat=${location.latitude}" +
+ "&lon=${location.longitude}" +
+ "×tamp=$timestamp" +
+ (location.speed?.let { "&speed=${it * 3.6}" } ?: "") + // m/s to km/h
+ (location.heading?.let { "&bearing=$it" } ?: "") +
+ (location.accuracy?.let { "&accuracy=$it" } ?: "") +
+ (location.altitude?.let { "&altitude=$it" } ?: "") +
+ "&is_moving=${if (location.isMoving) 1 else 0}"
+ }
+
+ private fun buildRequest(config: TraccarConfig, url: String): Request {
+ val builder = Request.Builder().url(url).get()
+
+ if (config.password.isNotEmpty()) {
+ val credentials = "${config.deviceId}:${config.password}"
+ val encoded = Base64.encodeToString(credentials.toByteArray(), Base64.NO_WRAP)
+ builder.addHeader("Authorization", "Basic $encoded")
+ }
+
+ return builder.build()
+ }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add android/app/src/main/kotlin/com/traccar/traccar_client/network/TraccarHttpClient.kt
+git commit -m "feat: add TraccarHttpClient for server communication"
+```
+
+---
+
+## Task 7: Create ConnectivityReceiver
+
+**Files:**
+- Create: `android/app/src/main/kotlin/com/traccar/traccar_client/network/ConnectivityReceiver.kt`
+
+- [ ] **Step 1: Create ConnectivityReceiver.kt**
+
+```kotlin
+package com.traccar.traccar_client.network
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+
+class ConnectivityReceiver(
+ private val onConnectivityChange: (Boolean) -> Unit
+) : BroadcastReceiver() {
+
+ private var isNetworkAvailable = false
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val newNetworkState = isNetworkAvailable(context)
+ if (newNetworkState != isNetworkAvailable) {
+ isNetworkAvailable = newNetworkState
+ onConnectivityChange(newNetworkAvailable)
+ }
+ }
+
+ companion object {
+ fun isNetworkAvailable(context: Context): Boolean {
+ val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val network = connectivityManager.activeNetwork ?: return false
+ val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
+ return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add android/app/src/main/kotlin/com/traccar/traccar_client/network/ConnectivityReceiver.kt
+git commit -m "feat: add ConnectivityReceiver for network state monitoring"
+```
+
+---
+
+## Task 8: Create HeartbeatScheduler
+
+**Files:**
+- Create: `android/app/src/main/kotlin/com/traccar/traccar_client/location/HeartbeatScheduler.kt`
+
+- [ ] **Step 1: Create HeartbeatScheduler.kt**
+
+```kotlin
+package com.traccar.traccar_client.location
+
+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
+
+class HeartbeatScheduler(private val context: Context) {
+
+ private val workManager = WorkManager.getInstance(context)
+
+ fun scheduleHeartbeat(intervalSeconds: Long, onHeartbeat: () -> Unit) {
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.NOT_REQUIRED)
+ .build()
+
+ val heartbeatRequest = PeriodicWorkRequestBuilder(
+ intervalSeconds, TimeUnit.SECONDS
+ )
+ .setConstraints(constraints)
+ .addTag(HEARTBEAT_WORK_TAG)
+ .build()
+
+ workManager.enqueueUniquePeriodicWork(
+ HEARTBEAT_WORK_NAME,
+ ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
+ heartbeatRequest
+ )
+ }
+
+ fun cancelHeartbeat() {
+ workManager.cancelUniqueWork(HEARTBEAT_WORK_NAME)
+ }
+
+ class HeartbeatWorker(
+ context: Context,
+ params: WorkerParameters
+ ) : Worker(context, params) {
+ override fun doWork(): Result {
+ // Heartbeat is triggered - location service will handle the actual location fix
+ return Result.success()
+ }
+ }
+
+ companion object {
+ const val HEARTBEAT_WORK_TAG = "traccar_heartbeat"
+ const val HEARTBEAT_WORK_NAME = "traccar_heartbeat_work"
+ }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add android/app/src/main/kotlin/com/traccar/traccar_client/location/HeartbeatScheduler.kt
+git commit -m "feat: add HeartbeatScheduler with WorkManager"
+```
+
+---
+
+## Task 9: Create LocationTrackingService
+
+**Files:**
+- Create: `android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt`
+
+- [ ] **Step 1: Create LocationTrackingService.kt**
+
+```kotlin
+package com.traccar.traccar_client.service
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.location.Location
+import android.os.Binder
+import android.os.Build
+import android.os.IBinder
+import android.os.PowerManager
+import androidx.core.app.NotificationCompat
+import com.traccar.traccar_client.MainActivity
+import com.traccar.traccar_client.R
+import com.traccar.traccar_client.location.DistanceFilterProcessor
+import com.traccar.traccar_client.location.FusedLocationProvider
+import com.traccar.traccar_client.location.HeartbeatScheduler
+import com.traccar.traccar_client.model.Location as TraccarLocation
+import com.traccar.traccar_client.network.ConnectivityReceiver
+import com.traccar.traccar_client.network.TraccarConfig
+import com.traccar.traccar_client.network.TraccarHttpClient
+import com.traccar.traccar_client.storage.AppDatabase
+import com.traccar.traccar_client.storage.EventLogEntity
+import com.traccar.traccar_client.storage.LocationEntity
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+
+class LocationTrackingService : Service() {
+
+ private val binder = LocalBinder()
+ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+
+ private lateinit var fusedLocationProvider: FusedLocationProvider
+ private lateinit var distanceFilterProcessor: DistanceFilterProcessor
+ private lateinit var heartbeatScheduler: HeartbeatScheduler
+ private lateinit var database: AppDatabase
+ private lateinit var httpClient: TraccarHttpClient
+ private lateinit var connectivityReceiver: ConnectivityReceiver
+
+ private var wakeLock: PowerManager.WakeLock? = null
+ private var isTracking = false
+ private var isNetworkAvailable = true
+
+ private var currentConfig: TrackingConfig? = null
+
+ inner class LocalBinder : Binder() {
+ fun getService(): LocationTrackingService = this@LocationTrackingService
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ fusedLocationProvider = FusedLocationProvider(this)
+ distanceFilterProcessor = DistanceFilterProcessor()
+ heartbeatScheduler = HeartbeatScheduler(this)
+ database = AppDatabase.getInstance(this)
+ httpClient = TraccarHttpClient()
+ connectivityReceiver = ConnectivityReceiver { online ->
+ isNetworkAvailable = online
+ logEvent("NETWORK_CHANGE", "Network: ${if (online) "online" else "offline"}")
+ if (online) syncBufferedLocations()
+ }
+
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_START -> startTracking()
+ ACTION_STOP -> stopTracking()
+ }
+ return START_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder = binder
+
+ override fun onDestroy() {
+ super.onDestroy()
+ stopTracking()
+ serviceScope.cancel()
+ }
+
+ fun startTracking() {
+ if (isTracking) return
+
+ val config = currentConfig ?: TrackingConfig()
+ currentConfig = config
+
+ acquireWakeLock()
+ startForeground(NOTIFICATION_ID, createNotification("Starting..."))
+
+ isTracking = true
+ distanceFilterProcessor.reset()
+
+ fusedLocationProvider.startLocationUpdates(
+ interval = config.interval * 1000L,
+ fastestInterval = config.fastestInterval * 1000L,
+ accuracy = config.accuracy
+ ) { location ->
+ onLocationReceived(location)
+ }
+
+ if (config.heartbeat > 0) {
+ heartbeatScheduler.scheduleHeartbeat(config.heartbeat.toLong()) {
+ onHeartbeat()
+ }
+ }
+
+ logEvent("INFO", "Tracking started")
+ }
+
+ fun stopTracking() {
+ if (!isTracking) return
+
+ isTracking = false
+ fusedLocationProvider.stopLocationUpdates()
+ heartbeatScheduler.cancelHeartbeat()
+ releaseWakeLock()
+
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+
+ logEvent("INFO", "Tracking stopped")
+ }
+
+ fun updateConfig(config: TrackingConfig) {
+ currentConfig = config
+ if (isTracking) {
+ stopTracking()
+ startTracking()
+ }
+ }
+
+ private fun onLocationReceived(location: Location) {
+ val config = currentConfig ?: return
+
+ val filterConfig = com.traccar.traccar_client.location.FilterConfig(
+ distanceFilter = config.distanceFilter,
+ intervalFilter = config.interval,
+ angleFilter = config.angleFilter
+ )
+
+ if (!distanceFilterProcessor.shouldAccept(location, filterConfig)) {
+ logEvent("FILTERED", "Location filtered by distance/interval/angle")
+ return
+ }
+
+ val traccarLocation = TraccarLocation(
+ timestamp = location.time,
+ latitude = location.latitude,
+ longitude = location.longitude,
+ accuracy = if (location.hasAccuracy()) location.accuracy else null,
+ speed = if (location.hasSpeed()) location.speed else null,
+ heading = if (location.hasBearing()) location.bearing else null,
+ altitude = if (location.hasAltitude()) location.altitude else null,
+ isMoving = location.speed > 1.0f // Consider moving if speed > 1 m/s
+ )
+
+ logEvent("LOCATION", "Lat: ${location.latitude}, Lon: ${location.longitude}, Speed: ${location.speed}")
+
+ updateNotification(location)
+
+ if (isNetworkAvailable) {
+ sendLocationToServer(traccarLocation)
+ } else {
+ bufferLocation(traccarLocation)
+ }
+ }
+
+ private fun onHeartbeat() {
+ logEvent("HEARTBEAT", "Heartbeat fired")
+ fusedLocationProvider.getLastLocation { location ->
+ location?.let { onLocationReceived(it) }
+ }
+ }
+
+ private fun sendLocationToServer(location: TraccarLocation) {
+ val config = currentConfig ?: return
+ val traccarConfig = TraccarConfig(
+ serverUrl = config.serverUrl,
+ deviceId = config.deviceId,
+ password = config.password
+ )
+
+ serviceScope.launch {
+ val result = httpClient.sendLocation(traccarConfig, location)
+ if (result.isSuccess) {
+ logEvent("SYNC", "Location sent to server")
+ } else {
+ logEvent("ERROR", "Failed to send location: ${result.exceptionOrNull()?.message}")
+ bufferLocation(location)
+ }
+ }
+ }
+
+ private fun bufferLocation(location: TraccarLocation) {
+ serviceScope.launch {
+ val entity = LocationEntity(
+ timestamp = location.timestamp,
+ latitude = location.latitude,
+ longitude = location.longitude,
+ accuracy = location.accuracy,
+ speed = location.speed,
+ heading = location.heading,
+ altitude = location.altitude,
+ isMoving = location.isMoving,
+ synced = false
+ )
+ database.locationDao().insert(entity)
+ }
+ }
+
+ private fun syncBufferedLocations() {
+ val config = currentConfig ?: return
+ val traccarConfig = TraccarConfig(
+ serverUrl = config.serverUrl,
+ deviceId = config.deviceId,
+ password = config.password
+ )
+
+ serviceScope.launch {
+ val unsyncedLocations = database.locationDao().getUnsyncedLocations()
+ if (unsyncedLocations.isEmpty()) return@launch
+
+ val locations = unsyncedLocations.map { entity ->
+ TraccarLocation(
+ timestamp = entity.timestamp,
+ latitude = entity.latitude,
+ longitude = entity.longitude,
+ accuracy = entity.accuracy,
+ speed = entity.speed,
+ heading = entity.heading,
+ altitude = entity.altitude,
+ isMoving = entity.isMoving
+ )
+ }
+
+ val result = httpClient.sendLocations(traccarConfig, locations)
+ if (result.isSuccess) {
+ val ids = unsyncedLocations.map { it.id }
+ database.locationDao().markAllAsSynced(ids)
+ logEvent("SYNC", "Synced ${result.getOrNull()} buffered locations")
+ }
+ }
+ }
+
+ private fun logEvent(eventType: String, message: String) {
+ serviceScope.launch {
+ val entry = EventLogEntity(
+ timestamp = System.currentTimeMillis(),
+ eventType = eventType,
+ message = message
+ )
+ database.eventLogDao().insert(entry)
+ }
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "Traccar Tracking",
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = "Location tracking notification"
+ setShowBadge(false)
+ }
+
+ val notificationManager = getSystemService(NotificationManager::class.java)
+ notificationManager.createNotificationChannel(channel)
+ }
+ }
+
+ private fun createNotification(content: String): Notification {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("📍 Traccar Client")
+ .setContentText(content)
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .build()
+ }
+
+ private fun updateNotification(location: Location) {
+ val content = buildString {
+ append("Lat: %.4f Lon: %.4f".format(location.latitude, location.longitude))
+ if (location.hasSpeed()) {
+ append("\nSpeed: %.0f km/h".format(location.speed * 3.6))
+ }
+ val time = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault())
+ .format(java.util.Date(location.time))
+ append("\nLast: $time")
+ }
+
+ val notification = createNotification(content)
+ val notificationManager = getSystemService(NotificationManager::class.java)
+ notificationManager.notify(NOTIFICATION_ID, notification)
+ }
+
+ private fun acquireWakeLock() {
+ val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
+ wakeLock = powerManager.newWakeLock(
+ PowerManager.PARTIAL_WAKE_LOCK,
+ "TraccarClient::LocationWakeLock"
+ ).apply {
+ acquire(10 * 60 * 60 * 1000L) // 10 hours max
+ }
+ }
+
+ private fun releaseWakeLock() {
+ wakeLock?.let {
+ if (it.isHeld) it.release()
+ wakeLock = null
+ }
+ }
+
+ companion object {
+ const val ACTION_START = "com.traccar.traccar_client.ACTION_START"
+ const val ACTION_STOP = "com.traccar.traccar_client.ACTION_STOP"
+ private const val CHANNEL_ID = "traccar_tracking_channel"
+ private const val NOTIFICATION_ID = 1
+ }
+}
+
+data class TrackingConfig(
+ val serverUrl: String = "https://demo.traccar.org",
+ val deviceId: String = "",
+ val password: String = "",
+ val accuracy: Int = 0, // 0=HIGH, 1=HIGH_ACCURACY, 2=BALANCED, 3=LOW
+ val interval: Int = 300,
+ val fastestInterval: Int = 30,
+ val distanceFilter: Int = 75,
+ val angleFilter: Int = 0,
+ val heartbeat: Int = 60,
+ val offlineBuffer: Boolean = true,
+ val stopDetection: Boolean = true
+)
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt
+git commit -m "feat: add LocationTrackingService foreground service"
+```
+
+---
+
+## Task 10: Create Flutter Preferences
+
+**Files:**
+- Create: `lib/preferences.dart`
+
+- [ ] **Step 1: Create lib/preferences.dart**
+
+```dart
+import 'package:shared_preferences/shared_preferences.dart';
+
+class Preferences {
+ static const String keyServerUrl = 'server_url';
+ static const String keyDeviceId = 'device_id';
+ static const String keyPassword = 'password';
+ static const String keyAccuracy = 'accuracy';
+ static const String keyDistanceFilter = 'distance_filter';
+ static const String keyInterval = 'interval';
+ static const String keyFastestInterval = 'fastest_interval';
+ static const String keyAngleFilter = 'angle_filter';
+ static const String keyHeartbeat = 'heartbeat';
+ static const String keyOfflineBuffer = 'offline_buffer';
+ static const String keyStopDetection = 'stop_detection';
+
+ static SharedPreferences? _instance;
+
+ static Future init() async {
+ _instance = await SharedPreferences.getInstance();
+ }
+
+ static SharedPreferences get instance {
+ if (_instance == null) {
+ throw Exception('Preferences not initialized. Call Preferences.init() first.');
+ }
+ return _instance!;
+ }
+
+ static String get serverUrl =>
+ instance.getString(keyServerUrl) ?? 'https://demo.traccar.org';
+
+ static String get deviceId =>
+ instance.getString(keyDeviceId) ?? _generateDeviceId();
+
+ static String get password => instance.getString(keyPassword) ?? '';
+
+ static int get accuracy => instance.getInt(keyAccuracy) ?? 0;
+
+ static int get distanceFilter => instance.getInt(keyDistanceFilter) ?? 75;
+
+ static int get interval => instance.getInt(keyInterval) ?? 300;
+
+ static int get fastestInterval => instance.getInt(keyFastestInterval) ?? 30;
+
+ static int get angleFilter => instance.getInt(keyAngleFilter) ?? 0;
+
+ static int get heartbeat => instance.getInt(keyHeartbeat) ?? 60;
+
+ static bool get offlineBuffer => instance.getBool(keyOfflineBuffer) ?? true;
+
+ static bool get stopDetection => instance.getBool(keyStopDetection) ?? true;
+
+ static Future setServerUrl(String value) async {
+ await instance.setString(keyServerUrl, value);
+ }
+
+ static Future setDeviceId(String value) async {
+ await instance.setString(keyDeviceId, value);
+ }
+
+ static Future setPassword(String value) async {
+ await instance.setString(keyPassword, value);
+ }
+
+ static Future setAccuracy(int value) async {
+ await instance.setInt(keyAccuracy, value);
+ }
+
+ static Future setDistanceFilter(int value) async {
+ await instance.setInt(keyDistanceFilter, value);
+ }
+
+ static Future setInterval(int value) async {
+ await instance.setInt(keyInterval, value);
+ }
+
+ static Future setFastestInterval(int value) async {
+ await instance.setInt(keyFastestInterval, value);
+ }
+
+ static Future setAngleFilter(int value) async {
+ await instance.setInt(keyAngleFilter, value);
+ }
+
+ static Future setHeartbeat(int value) async {
+ await instance.setInt(keyHeartbeat, value);
+ }
+
+ static Future setOfflineBuffer(bool value) async {
+ await instance.setBool(keyOfflineBuffer, value);
+ }
+
+ static Future setStopDetection(bool value) async {
+ await instance.setBool(keyStopDetection, value);
+ }
+
+ static String _generateDeviceId() {
+ final random = DateTime.now().millisecondsSinceEpoch % 100000000;
+ final deviceId = random.toString().padLeft(8, '0');
+ instance.setString(keyDeviceId, deviceId);
+ return deviceId;
+ }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add lib/preferences.dart
+git commit -m "feat: add Preferences for settings persistence"
+```
+
+---
+
+## Task 11: Create Platform Bridge
+
+**Files:**
+- Create: `lib/bridge/location_bridge.dart`
+- Modify: `android/app/src/main/kotlin/com/traccar/traccar_client/MainActivity.kt`
+- Create: `android/app/src/main/kotlin/com/traccar/traccar_client/BridgeModule.kt`
+
+- [ ] **Step 1: Create lib/bridge/location_bridge.dart**
+
+```dart
+import 'dart:async';
+import 'package:flutter/services.dart';
+
+class LocationBridge {
+ static const MethodChannel _methodChannel =
+ MethodChannel('com.traccar.client/tracking');
+ static const EventChannel _eventChannel =
+ EventChannel('com.traccar.client/location_updates');
+
+ static Stream