Add GPS tracking functionality with location services integration

- Introduced TrackingRepository to manage activity tracking state and GPS points.
- Implemented LocationTrackingService for foreground GPS tracking using FusedLocationProviderClient.
- Created data models GpsPoint and TrackingState to represent tracking data.
- Enhanced TrackActivityScreen with UI for selecting activity type and displaying tracking status.
- Added LocationUtils for geospatial calculations, including distance tracking and loop closure detection.
- Updated build.gradle.kts to include Google Play Services Location dependency.
This commit is contained in:
Redsandy
2026-03-15 00:12:08 +03:00
parent ffc5d0887e
commit 02171118ec
10 changed files with 711 additions and 26 deletions

View File

@@ -117,6 +117,9 @@ dependencies {
// MapLibre (free, no API key needed) // MapLibre (free, no API key needed)
implementation(libs.maplibre.compose) implementation(libs.maplibre.compose)
// Google Play Services Location
implementation(libs.play.services.location)
// Firebase (uncomment when google-services.json is added) // Firebase (uncomment when google-services.json is added)
// implementation(platform(libs.firebase.bom)) // implementation(platform(libs.firebase.bom))
// implementation(libs.firebase.messaging) // implementation(libs.firebase.messaging)

View File

@@ -0,0 +1,54 @@
package com.geozoner.app.data.repository
import com.geozoner.app.domain.model.GpsPoint
import com.geozoner.app.domain.model.TrackingState
import com.geozoner.app.util.LocationUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton
/**
* Single source of truth for activity tracking state.
* Shared between LocationTrackingService and UI (ViewModel).
*/
@Singleton
class TrackingRepository @Inject constructor() {
private val _state = MutableStateFlow(TrackingState())
val state: StateFlow<TrackingState> = _state.asStateFlow()
fun startTracking(activityType: String) {
_state.value = TrackingState(
isTracking = true,
activityType = activityType,
startTimeMillis = System.currentTimeMillis(),
)
}
fun addPoint(point: GpsPoint) {
_state.update { current ->
if (!current.isTracking) return@update current
val updatedPoints = current.points + point
val distance = LocationUtils.trackDistance(updatedPoints)
val distToStart = LocationUtils.distanceToStart(updatedPoints)
val loopClosed = LocationUtils.isLoopClosed(updatedPoints)
current.copy(
points = updatedPoints,
distanceMeters = distance,
distanceToStartMeters = distToStart,
isLoopClosed = loopClosed,
)
}
}
fun stopTracking(): TrackingState {
val finalState = _state.value.copy(isTracking = false)
_state.value = TrackingState() // reset
return finalState
}
fun getCurrentState(): TrackingState = _state.value
}

View File

@@ -0,0 +1,12 @@
package com.geozoner.app.domain.model
/**
* A single GPS coordinate recorded during activity tracking.
*/
data class GpsPoint(
val lat: Double,
val lon: Double,
val timestamp: Long = System.currentTimeMillis(),
val altitude: Double? = null,
val accuracy: Float? = null,
)

View File

@@ -0,0 +1,14 @@
package com.geozoner.app.domain.model
/**
* Represents the current state of activity tracking.
*/
data class TrackingState(
val isTracking: Boolean = false,
val activityType: String = "run",
val points: List<GpsPoint> = emptyList(),
val startTimeMillis: Long = 0L,
val distanceMeters: Double = 0.0,
val isLoopClosed: Boolean = false,
val distanceToStartMeters: Double = Double.MAX_VALUE,
)

View File

@@ -1,44 +1,123 @@
package com.geozoner.app.service package com.geozoner.app.service
import android.annotation.SuppressLint
import android.app.Notification import android.app.Notification
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.geozoner.app.GeoZonerApp import com.geozoner.app.GeoZonerApp
import com.geozoner.app.R import com.geozoner.app.R
import com.geozoner.app.data.repository.TrackingRepository
import com.geozoner.app.domain.model.GpsPoint
import com.geozoner.app.ui.MainActivity import com.geozoner.app.ui.MainActivity
import com.geozoner.app.util.Constants
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
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/** /**
* Foreground service for GPS tracking during activity recording. * Foreground service that records GPS points during an activity.
* TODO: Implement FusedLocationProviderClient, GPS point collection, * Uses FusedLocationProviderClient for battery-efficient location updates.
* loop detection, and broadcasting location updates to UI.
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class LocationTrackingService : Service() { class LocationTrackingService : Service() {
companion object {
const val ACTION_START = "ACTION_START_TRACKING"
const val ACTION_STOP = "ACTION_STOP_TRACKING"
const val EXTRA_ACTIVITY_TYPE = "EXTRA_ACTIVITY_TYPE"
private const val NOTIFICATION_ID = 1001
private const val TAG = "LocationTrackingService"
}
@Inject
lateinit var trackingRepository: TrackingRepository
private lateinit var fusedLocationClient: FusedLocationProviderClient
private lateinit var locationCallback: LocationCallback
override fun onCreate() {
super.onCreate()
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
for (location in result.locations) {
// Filter by accuracy (similar to hdop filtering on backend)
if (location.accuracy > Constants.MAX_GPS_ACCURACY_M) {
Log.d(TAG, "Skipping inaccurate point: accuracy=${location.accuracy}")
continue
}
val point = GpsPoint(
lat = location.latitude,
lon = location.longitude,
timestamp = location.time,
altitude = if (location.hasAltitude()) location.altitude else null,
accuracy = location.accuracy,
)
trackingRepository.addPoint(point)
Log.d(TAG, "Point added: ${point.lat}, ${point.lon} (accuracy=${point.accuracy})")
}
}
}
}
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) { when (intent?.action) {
ACTION_START -> startTracking() ACTION_START -> {
val activityType = intent.getStringExtra(EXTRA_ACTIVITY_TYPE) ?: "run"
startTracking(activityType)
}
ACTION_STOP -> stopTracking() ACTION_STOP -> stopTracking()
} }
return START_STICKY return START_STICKY
} }
private fun startTracking() { @SuppressLint("MissingPermission")
private fun startTracking(activityType: String) {
trackingRepository.startTracking(activityType)
val notification = createNotification() val notification = createNotification()
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
// TODO: Start GPS location updates via FusedLocationProviderClient
val locationRequest = LocationRequest.Builder(
Priority.PRIORITY_HIGH_ACCURACY,
Constants.GPS_INTERVAL_FOREGROUND_MS,
)
.setMinUpdateIntervalMillis(Constants.GPS_INTERVAL_FOREGROUND_MS / 2)
.setWaitForAccurateLocation(true)
.build()
fusedLocationClient.requestLocationUpdates(
locationRequest,
locationCallback,
Looper.getMainLooper(),
)
Log.d(TAG, "GPS tracking started: type=$activityType")
} }
private fun stopTracking() { private fun stopTracking() {
// TODO: Stop GPS location updates, collect final track fusedLocationClient.removeLocationUpdates(locationCallback)
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
Log.d(TAG, "GPS tracking stopped")
}
override fun onDestroy() {
super.onDestroy()
fusedLocationClient.removeLocationUpdates(locationCallback)
} }
private fun createNotification(): Notification { private fun createNotification(): Notification {
@@ -57,10 +136,4 @@ class LocationTrackingService : Service() {
.setOngoing(true) .setOngoing(true)
.build() .build()
} }
companion object {
const val ACTION_START = "ACTION_START_TRACKING"
const val ACTION_STOP = "ACTION_STOP_TRACKING"
private const val NOTIFICATION_ID = 1001
}
} }

View File

@@ -1,29 +1,180 @@
package com.geozoner.app.ui.activity package com.geozoner.app.ui.activity
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.automirrored.filled.DirectionsRun
import androidx.compose.material.icons.automirrored.filled.DirectionsBike
import androidx.compose.material.icons.automirrored.filled.DirectionsWalk
import androidx.compose.material.icons.filled.Terrain
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import com.geozoner.app.R import com.geozoner.app.R
import com.geozoner.app.ui.theme.Green40
import kotlinx.coroutines.delay
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.layers.LineLayer
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.compose.sources.rememberGeoJsonSource
private const val OPENFREEMAP_STYLE = "https://tiles.openfreemap.org/styles/liberty"
data class ActivityTypeOption(
val type: String,
val label: String,
val icon: ImageVector,
)
private val activityTypes = listOf(
ActivityTypeOption("run", "Run", Icons.AutoMirrored.Filled.DirectionsRun),
ActivityTypeOption("cycle", "Cycle", Icons.AutoMirrored.Filled.DirectionsBike),
ActivityTypeOption("walk", "Walk", Icons.AutoMirrored.Filled.DirectionsWalk),
ActivityTypeOption("hike", "Hike", Icons.Default.Terrain),
)
/**
* Activity tracking screen — will show real-time GPS track on map,
* distance/time counters, and start/stop controls.
* TODO: GPS foreground service, live map polyline, loop detection.
*/
@Composable @Composable
fun TrackActivityScreen( fun TrackActivityScreen(
onActivityFinished: () -> Unit, onActivityFinished: () -> Unit,
viewModel: TrackActivityViewModel = hiltViewModel(),
) {
val trackingState by viewModel.trackingState.collectAsState()
val uploadState by viewModel.uploadState.collectAsState()
val context = LocalContext.current
var selectedType by rememberSaveable { mutableStateOf("run") }
var hasLocationPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
)
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
hasLocationPermission = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true
}
// Handle upload success
LaunchedEffect(uploadState.result) {
if (uploadState.result != null) {
delay(2000)
viewModel.clearUploadState()
onActivityFinished()
}
}
// Upload result dialog
uploadState.result?.let { result ->
AlertDialog(
onDismissRequest = { },
confirmButton = {
TextButton(onClick = {
viewModel.clearUploadState()
onActivityFinished()
}) { Text("OK") }
},
icon = { Icon(Icons.Default.CheckCircle, null, tint = Green40, modifier = Modifier.size(48.dp)) },
title = { Text("Activity Completed!") },
text = {
Column {
Text("Distance: ${String.format("%.0f", result.distanceM ?: 0.0)} m")
if (result.areaM2 != null) {
Text("Zone area: ${String.format("%.0f", result.areaM2)}")
} else {
Text("No zone created (loop not closed or too small)")
}
}
},
)
}
uploadState.error?.let { error ->
AlertDialog(
onDismissRequest = { viewModel.clearUploadState() },
confirmButton = { TextButton(onClick = { viewModel.clearUploadState() }) { Text("OK") } },
title = { Text("Upload Error") },
text = { Text(error) },
)
}
if (trackingState.isTracking) {
TrackingActiveContent(
trackingState = trackingState,
startTimeMillis = trackingState.startTimeMillis,
isUploading = uploadState.isUploading,
onFinish = { viewModel.finishAndUpload() },
)
} else {
PreTrackingContent(
selectedType = selectedType,
onTypeSelected = { selectedType = it },
hasLocationPermission = hasLocationPermission,
onRequestPermission = {
permissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
)
)
},
onStart = { viewModel.startTracking(selectedType) },
)
}
}
@Composable
private fun PreTrackingContent(
selectedType: String,
onTypeSelected: (String) -> Unit,
hasLocationPermission: Boolean,
onRequestPermission: () -> Unit,
onStart: () -> Unit,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -33,21 +184,231 @@ fun TrackActivityScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
text = "Track Activity", text = stringResource(R.string.select_activity_type),
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Row(
text = "GPS tracking will be implemented here.\nSelect activity type, start recording, see live route.", horizontalArrangement = Arrangement.spacedBy(8.dp),
style = MaterialTheme.typography.bodyLarge, ) {
activityTypes.forEach { option ->
FilterChip(
selected = selectedType == option.type,
onClick = { onTypeSelected(option.type) },
label = { Text(option.label) },
leadingIcon = { Icon(option.icon, contentDescription = option.label, modifier = Modifier.size(18.dp)) },
) )
}
}
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
Button(onClick = onActivityFinished) { if (!hasLocationPermission) {
Text(stringResource(R.string.start_activity)) Text(
text = "Location permission is required to track activities",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = onRequestPermission) {
Text("Grant Location Permission")
}
} else {
Button(
onClick = onStart,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(16.dp),
) {
Text(
text = stringResource(R.string.start_activity),
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
)
}
} }
} }
} }
@Composable
private fun TrackingActiveContent(
trackingState: com.geozoner.app.domain.model.TrackingState,
startTimeMillis: Long,
isUploading: Boolean,
onFinish: () -> Unit,
) {
// Timer
var elapsedSeconds by remember { mutableLongStateOf(0L) }
LaunchedEffect(startTimeMillis) {
while (true) {
elapsedSeconds = (System.currentTimeMillis() - startTimeMillis) / 1000
delay(1000)
}
}
val hours = elapsedSeconds / 3600
val minutes = (elapsedSeconds % 3600) / 60
val seconds = elapsedSeconds % 60
val timeString = if (hours > 0) {
String.format("%d:%02d:%02d", hours, minutes, seconds)
} else {
String.format("%02d:%02d", minutes, seconds)
}
// Build GeoJSON for live track
val trackGeoJson = remember(trackingState.points.size) {
if (trackingState.points.size < 2) ""
else {
val coords = trackingState.points.joinToString(",") { "[${it.lon},${it.lat}]" }
"""{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"LineString","coordinates":[$coords]},"properties":{}}]}"""
}
}
Column(modifier = Modifier.fillMaxSize()) {
// Mini map with live track
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
MaplibreMap(
modifier = Modifier.fillMaxSize(),
baseStyle = org.maplibre.compose.style.BaseStyle.Uri(OPENFREEMAP_STYLE),
) {
if (trackGeoJson.isNotEmpty()) {
val trackSource = rememberGeoJsonSource(
data = GeoJsonData.JsonString(trackGeoJson),
)
LineLayer(
id = "live-track",
source = trackSource,
color = const(Color(0xFF4CAF50)),
width = const(4.dp),
opacity = const(0.9f),
)
}
}
// Loop closure banner
if (trackingState.isLoopClosed) {
Card(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 16.dp),
colors = CardDefaults.cardColors(
containerColor = Green40,
),
) {
Text(
text = stringResource(R.string.loop_detected),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = Color.White,
fontWeight = FontWeight.Bold,
)
}
}
}
// Stats panel
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Timer
Text(
text = timeString,
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(8.dp))
// Distance and points
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
StatItem(
label = "Distance",
value = if (trackingState.distanceMeters >= 1000) {
String.format("%.2f km", trackingState.distanceMeters / 1000)
} else {
String.format("%.0f m", trackingState.distanceMeters)
},
)
StatItem(
label = "Points",
value = "${trackingState.points.size}",
)
StatItem(
label = "To start",
value = if (trackingState.distanceToStartMeters < 10000) {
String.format("%.0f m", trackingState.distanceToStartMeters)
} else {
"-"
},
)
}
Spacer(modifier = Modifier.height(16.dp))
// Finish button
Button(
onClick = onFinish,
enabled = !isUploading && trackingState.points.size >= 4,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
shape = RoundedCornerShape(16.dp),
) {
if (isUploading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
Text("Uploading...")
} else {
Text(
text = stringResource(R.string.finish_activity),
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
)
}
}
}
}
}
}
@Composable
private fun StatItem(label: String, value: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@@ -0,0 +1,102 @@
package com.geozoner.app.ui.activity
import android.content.Context
import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geozoner.app.data.remote.api.ActivityApi
import com.geozoner.app.data.remote.dto.ActivityCreateRequest
import com.geozoner.app.data.remote.dto.ActivityDetailResponse
import com.geozoner.app.data.remote.dto.GpsPointDto
import com.geozoner.app.data.repository.TrackingRepository
import com.geozoner.app.domain.model.TrackingState
import com.geozoner.app.service.LocationTrackingService
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import javax.inject.Inject
data class UploadUiState(
val isUploading: Boolean = false,
val result: ActivityDetailResponse? = null,
val error: String? = null,
)
@HiltViewModel
class TrackActivityViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
private val trackingRepository: TrackingRepository,
private val activityApi: ActivityApi,
) : ViewModel() {
val trackingState: StateFlow<TrackingState> = trackingRepository.state
private val _uploadState = MutableStateFlow(UploadUiState())
val uploadState: StateFlow<UploadUiState> = _uploadState.asStateFlow()
fun startTracking(activityType: String) {
val intent = Intent(appContext, LocationTrackingService::class.java).apply {
action = LocationTrackingService.ACTION_START
putExtra(LocationTrackingService.EXTRA_ACTIVITY_TYPE, activityType)
}
appContext.startForegroundService(intent)
}
fun stopTracking() {
val intent = Intent(appContext, LocationTrackingService::class.java).apply {
action = LocationTrackingService.ACTION_STOP
}
appContext.startService(intent)
}
fun finishAndUpload() {
val finalState = trackingRepository.stopTracking()
stopTracking()
if (finalState.points.size < 4) {
_uploadState.value = UploadUiState(error = "Too few GPS points recorded")
return
}
viewModelScope.launch {
_uploadState.value = UploadUiState(isUploading = true)
try {
val formatter = DateTimeFormatter.ISO_INSTANT
val gpsTrack = finalState.points.map { point ->
GpsPointDto(
lat = point.lat,
lon = point.lon,
timestamp = formatter.format(Instant.ofEpochMilli(point.timestamp)),
altitude = point.altitude,
hdop = point.accuracy?.toDouble()?.let { it / 5.0 }, // rough accuracy-to-hdop
)
}
val request = ActivityCreateRequest(
type = finalState.activityType,
startedAt = formatter.format(
Instant.ofEpochMilli(finalState.startTimeMillis)
),
endedAt = formatter.format(Instant.now()),
gpsTrack = gpsTrack,
)
val result = activityApi.createActivity(request)
_uploadState.value = UploadUiState(result = result)
} catch (e: Exception) {
_uploadState.value = UploadUiState(error = e.message ?: "Upload failed")
}
}
}
fun clearUploadState() {
_uploadState.value = UploadUiState()
}
}

View File

@@ -0,0 +1,60 @@
package com.geozoner.app.util
import com.geozoner.app.domain.model.GpsPoint
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Geo-math utilities for GPS tracking.
*/
object LocationUtils {
private const val EARTH_RADIUS_M = 6_371_000.0
/**
* Haversine distance between two points in meters.
*/
fun haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = sin(dLat / 2).pow(2) +
cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2)
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
return EARTH_RADIUS_M * c
}
/**
* Total distance along a track of GPS points.
*/
fun trackDistance(points: List<GpsPoint>): Double {
if (points.size < 2) return 0.0
var total = 0.0
for (i in 1 until points.size) {
total += haversineDistance(
points[i - 1].lat, points[i - 1].lon,
points[i].lat, points[i].lon,
)
}
return total
}
/**
* Distance from the last point to the first point (for loop closure detection).
*/
fun distanceToStart(points: List<GpsPoint>): Double {
if (points.size < 2) return Double.MAX_VALUE
val first = points.first()
val last = points.last()
return haversineDistance(first.lat, first.lon, last.lat, last.lon)
}
/**
* Check if the track forms a closed loop (start and end within radius).
*/
fun isLoopClosed(points: List<GpsPoint>, radiusM: Double = Constants.LOOP_CLOSURE_RADIUS_M): Boolean {
return distanceToStart(points) <= radiusM
}
}

View File

@@ -30,6 +30,9 @@ retrofitKotlinxSerialization = "1.0.0"
# MapLibre # MapLibre
maplibreCompose = "0.12.1" maplibreCompose = "0.12.1"
# Google Play Services
playServicesLocation = "21.3.0"
# Firebase # Firebase
firebaseBom = "33.7.0" firebaseBom = "33.7.0"
@@ -88,6 +91,9 @@ retrofit-kotlinx-serialization = { group = "com.jakewharton.retrofit", name = "r
# MapLibre # MapLibre
maplibre-compose = { module = "org.maplibre.compose:maplibre-compose", version.ref = "maplibreCompose" } maplibre-compose = { module = "org.maplibre.compose:maplibre-compose", version.ref = "maplibreCompose" }
# Google Play Services
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
# Firebase # Firebase
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" }