From 02171118ec02a761359ea11666981e4b45b13caa Mon Sep 17 00:00:00 2001 From: Redsandy <34872843+Redsandyg@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:12:08 +0300 Subject: [PATCH] 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. --- ...kotlin-compiler-2129342199983897734.salive | 0 app/build.gradle.kts | 3 + .../app/data/repository/TrackingRepository.kt | 54 +++ .../com/geozoner/app/domain/model/GpsPoint.kt | 12 + .../app/domain/model/TrackingState.kt | 14 + .../app/service/LocationTrackingService.kt | 99 ++++- .../app/ui/activity/TrackActivityScreen.kt | 387 +++++++++++++++++- .../app/ui/activity/TrackActivityViewModel.kt | 102 +++++ .../com/geozoner/app/util/LocationUtils.kt | 60 +++ gradle/libs.versions.toml | 6 + 10 files changed, 711 insertions(+), 26 deletions(-) create mode 100644 .kotlin/sessions/kotlin-compiler-2129342199983897734.salive create mode 100644 app/src/main/java/com/geozoner/app/data/repository/TrackingRepository.kt create mode 100644 app/src/main/java/com/geozoner/app/domain/model/GpsPoint.kt create mode 100644 app/src/main/java/com/geozoner/app/domain/model/TrackingState.kt create mode 100644 app/src/main/java/com/geozoner/app/ui/activity/TrackActivityViewModel.kt create mode 100644 app/src/main/java/com/geozoner/app/util/LocationUtils.kt diff --git a/.kotlin/sessions/kotlin-compiler-2129342199983897734.salive b/.kotlin/sessions/kotlin-compiler-2129342199983897734.salive new file mode 100644 index 0000000..e69de29 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a89561f..4f18835 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -117,6 +117,9 @@ dependencies { // MapLibre (free, no API key needed) implementation(libs.maplibre.compose) + // Google Play Services Location + implementation(libs.play.services.location) + // Firebase (uncomment when google-services.json is added) // implementation(platform(libs.firebase.bom)) // implementation(libs.firebase.messaging) diff --git a/app/src/main/java/com/geozoner/app/data/repository/TrackingRepository.kt b/app/src/main/java/com/geozoner/app/data/repository/TrackingRepository.kt new file mode 100644 index 0000000..4563d3e --- /dev/null +++ b/app/src/main/java/com/geozoner/app/data/repository/TrackingRepository.kt @@ -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 = _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 +} diff --git a/app/src/main/java/com/geozoner/app/domain/model/GpsPoint.kt b/app/src/main/java/com/geozoner/app/domain/model/GpsPoint.kt new file mode 100644 index 0000000..b9396e4 --- /dev/null +++ b/app/src/main/java/com/geozoner/app/domain/model/GpsPoint.kt @@ -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, +) diff --git a/app/src/main/java/com/geozoner/app/domain/model/TrackingState.kt b/app/src/main/java/com/geozoner/app/domain/model/TrackingState.kt new file mode 100644 index 0000000..7592ec7 --- /dev/null +++ b/app/src/main/java/com/geozoner/app/domain/model/TrackingState.kt @@ -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 = emptyList(), + val startTimeMillis: Long = 0L, + val distanceMeters: Double = 0.0, + val isLoopClosed: Boolean = false, + val distanceToStartMeters: Double = Double.MAX_VALUE, +) diff --git a/app/src/main/java/com/geozoner/app/service/LocationTrackingService.kt b/app/src/main/java/com/geozoner/app/service/LocationTrackingService.kt index 41d7db4..c4dc0cb 100644 --- a/app/src/main/java/com/geozoner/app/service/LocationTrackingService.kt +++ b/app/src/main/java/com/geozoner/app/service/LocationTrackingService.kt @@ -1,44 +1,123 @@ package com.geozoner.app.service +import android.annotation.SuppressLint import android.app.Notification import android.app.PendingIntent import android.app.Service import android.content.Intent import android.os.IBinder +import android.os.Looper +import android.util.Log import androidx.core.app.NotificationCompat import com.geozoner.app.GeoZonerApp 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.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 javax.inject.Inject /** - * Foreground service for GPS tracking during activity recording. - * TODO: Implement FusedLocationProviderClient, GPS point collection, - * loop detection, and broadcasting location updates to UI. + * Foreground service that records GPS points during an activity. + * Uses FusedLocationProviderClient for battery-efficient location updates. */ @AndroidEntryPoint 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 onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { - ACTION_START -> startTracking() + ACTION_START -> { + val activityType = intent.getStringExtra(EXTRA_ACTIVITY_TYPE) ?: "run" + startTracking(activityType) + } ACTION_STOP -> stopTracking() } return START_STICKY } - private fun startTracking() { + @SuppressLint("MissingPermission") + private fun startTracking(activityType: String) { + trackingRepository.startTracking(activityType) + val notification = createNotification() 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() { - // TODO: Stop GPS location updates, collect final track + fusedLocationClient.removeLocationUpdates(locationCallback) stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() + Log.d(TAG, "GPS tracking stopped") + } + + override fun onDestroy() { + super.onDestroy() + fusedLocationClient.removeLocationUpdates(locationCallback) } private fun createNotification(): Notification { @@ -57,10 +136,4 @@ class LocationTrackingService : Service() { .setOngoing(true) .build() } - - companion object { - const val ACTION_START = "ACTION_START_TRACKING" - const val ACTION_STOP = "ACTION_STOP_TRACKING" - private const val NOTIFICATION_ID = 1001 - } } diff --git a/app/src/main/java/com/geozoner/app/ui/activity/TrackActivityScreen.kt b/app/src/main/java/com/geozoner/app/ui/activity/TrackActivityScreen.kt index ae4734a..bcfaec7 100644 --- a/app/src/main/java/com/geozoner/app/ui/activity/TrackActivityScreen.kt +++ b/app/src/main/java/com/geozoner/app/ui/activity/TrackActivityScreen.kt @@ -1,29 +1,180 @@ 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.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.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.Text +import androidx.compose.material3.TextButton 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.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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign 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.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 fun TrackActivityScreen( 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)} m²") + } 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( modifier = Modifier @@ -33,21 +184,231 @@ fun TrackActivityScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = "Track Activity", + text = stringResource(R.string.select_activity_type), style = MaterialTheme.typography.headlineMedium, ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(24.dp)) - Text( - text = "GPS tracking will be implemented here.\nSelect activity type, start recording, see live route.", - style = MaterialTheme.typography.bodyLarge, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + 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)) - Button(onClick = onActivityFinished) { - Text(stringResource(R.string.start_activity)) + if (!hasLocationPermission) { + 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, + ) + } +} diff --git a/app/src/main/java/com/geozoner/app/ui/activity/TrackActivityViewModel.kt b/app/src/main/java/com/geozoner/app/ui/activity/TrackActivityViewModel.kt new file mode 100644 index 0000000..3b55cea --- /dev/null +++ b/app/src/main/java/com/geozoner/app/ui/activity/TrackActivityViewModel.kt @@ -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 = trackingRepository.state + + private val _uploadState = MutableStateFlow(UploadUiState()) + val uploadState: StateFlow = _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() + } +} diff --git a/app/src/main/java/com/geozoner/app/util/LocationUtils.kt b/app/src/main/java/com/geozoner/app/util/LocationUtils.kt new file mode 100644 index 0000000..8d0cb1f --- /dev/null +++ b/app/src/main/java/com/geozoner/app/util/LocationUtils.kt @@ -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): 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): 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, radiusM: Double = Constants.LOOP_CLOSURE_RADIUS_M): Boolean { + return distanceToStart(points) <= radiusM + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7c284e8..ed9af97 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,9 @@ retrofitKotlinxSerialization = "1.0.0" # MapLibre maplibreCompose = "0.12.1" +# Google Play Services +playServicesLocation = "21.3.0" + # Firebase firebaseBom = "33.7.0" @@ -88,6 +91,9 @@ retrofit-kotlinx-serialization = { group = "com.jakewharton.retrofit", name = "r # MapLibre 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-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" }