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)
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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" }
|
||||
|
||||
Reference in New Issue
Block a user