# 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