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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
12
app/src/main/java/com/geozoner/app/domain/model/GpsPoint.kt
Normal file
12
app/src/main/java/com/geozoner/app/domain/model/GpsPoint.kt
Normal 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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)} 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(
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/src/main/java/com/geozoner/app/util/LocationUtils.kt
Normal file
60
app/src/main/java/com/geozoner/app/util/LocationUtils.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user