Compare commits
4 Commits
4cc43a410b
...
879582d5ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
879582d5ee | ||
|
|
e2c825ed6f | ||
|
|
02171118ec | ||
|
|
ffc5d0887e |
@@ -30,13 +30,6 @@ android {
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// Inject Mapbox public token as a BuildConfig field
|
||||
buildConfigField(
|
||||
"String",
|
||||
"MAPBOX_PUBLIC_TOKEN",
|
||||
"\"${localProperties.getProperty("MAPBOX_PUBLIC_TOKEN", "")}\""
|
||||
)
|
||||
|
||||
// Backend base URL
|
||||
buildConfigField(
|
||||
"String",
|
||||
@@ -121,8 +114,11 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.retrofit.kotlinx.serialization)
|
||||
|
||||
// Mapbox (uncomment after setting MAPBOX_DOWNLOADS_TOKEN in local.properties)
|
||||
// implementation(libs.mapbox.maps)
|
||||
// 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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,321 @@
|
||||
package com.geozoner.app.ui.friends
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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.draw.clip
|
||||
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.hilt.navigation.compose.hiltViewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.geozoner.app.R
|
||||
import com.geozoner.app.data.remote.dto.UserResponse
|
||||
|
||||
/**
|
||||
* Friends screen — list friends, add by username, remove.
|
||||
* TODO: Fetch from GET /friends, POST /friends, DELETE /friends/{id}.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FriendsScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: FriendsViewModel = hiltViewModel(),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.friends_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
var showAddDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Show snackbar on add result or error
|
||||
LaunchedEffect(state.addFriendResult) {
|
||||
state.addFriendResult?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
viewModel.clearAddFriendState()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.addFriendError) {
|
||||
state.addFriendError?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
viewModel.clearAddFriendState()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.error) {
|
||||
state.error?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
viewModel.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
// Add Friend dialog
|
||||
if (showAddDialog) {
|
||||
AddFriendDialog(
|
||||
isAdding = state.isAdding,
|
||||
onDismiss = { showAddDialog = false },
|
||||
onAdd = { username ->
|
||||
viewModel.addFriend(username)
|
||||
showAddDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.friends_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { showAddDialog = true }) {
|
||||
Icon(Icons.Default.PersonAdd, contentDescription = stringResource(R.string.add_friend))
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadFriends() },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
Text(
|
||||
text = "Friends list will appear here",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
if (state.friends.isEmpty() && !state.isLoading) {
|
||||
// Empty state
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Default.PersonAdd,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "No friends yet",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "Tap + to add friends by username",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(
|
||||
items = state.friends,
|
||||
key = { it.id },
|
||||
) { friend ->
|
||||
FriendItem(
|
||||
friend = friend,
|
||||
onRemove = { viewModel.removeFriend(friend.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun FriendItem(
|
||||
friend: UserResponse,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { value ->
|
||||
if (value == SwipeToDismissBoxValue.EndToStart) {
|
||||
onRemove()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
backgroundContent = {
|
||||
val color by animateColorAsState(
|
||||
targetValue = if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart) {
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
label = "swipe-bg",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color)
|
||||
.padding(horizontal = 20.dp),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Remove friend",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
enableDismissFromStartToEnd = false,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Avatar
|
||||
if (friend.avatarUrl != null) {
|
||||
AsyncImage(
|
||||
model = friend.avatarUrl,
|
||||
contentDescription = "${friend.username} avatar",
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = friend.username.first().uppercase(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
// Info
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = friend.username,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = friend.email,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddFriendDialog(
|
||||
isAdding: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (String) -> Unit,
|
||||
) {
|
||||
var username by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.add_friend)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text(stringResource(R.string.add_friend_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isAdding,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onAdd(username.trim()) },
|
||||
enabled = username.isNotBlank() && !isAdding,
|
||||
) {
|
||||
if (isAdding) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Text(stringResource(R.string.add_friend))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.geozoner.app.ui.friends
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.geozoner.app.data.remote.api.FriendApi
|
||||
import com.geozoner.app.data.remote.dto.UserResponse
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class FriendsUiState(
|
||||
val friends: List<UserResponse> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val addFriendResult: String? = null,
|
||||
val addFriendError: String? = null,
|
||||
val isAdding: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class FriendsViewModel @Inject constructor(
|
||||
private val friendApi: FriendApi,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(FriendsUiState())
|
||||
val uiState: StateFlow<FriendsUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadFriends()
|
||||
}
|
||||
|
||||
fun loadFriends() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val friends = friendApi.getFriends()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
friends = friends,
|
||||
isLoading = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load friends",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addFriend(username: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isAdding = true, addFriendError = null, addFriendResult = null)
|
||||
try {
|
||||
val result = friendApi.addFriend(username)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isAdding = false,
|
||||
addFriendResult = result.detail,
|
||||
)
|
||||
loadFriends() // Refresh list
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isAdding = false,
|
||||
addFriendError = e.message ?: "Failed to add friend",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFriend(friendId: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
friendApi.removeFriend(friendId)
|
||||
// Remove from local state immediately
|
||||
_uiState.value = _uiState.value.copy(
|
||||
friends = _uiState.value.friends.filter { it.id != friendId },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = e.message ?: "Failed to remove friend",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAddFriendState() {
|
||||
_uiState.value = _uiState.value.copy(addFriendResult = null, addFriendError = null)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.value = _uiState.value.copy(error = null)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,247 @@
|
||||
package com.geozoner.app.ui.map
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MyLocation
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.geozoner.app.data.remote.dto.ZoneBriefResponse
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import com.google.android.gms.tasks.CancellationTokenSource
|
||||
import kotlinx.coroutines.launch
|
||||
import org.maplibre.compose.camera.CameraPosition
|
||||
import org.maplibre.compose.camera.rememberCameraState
|
||||
import org.maplibre.compose.expressions.dsl.const
|
||||
import org.maplibre.compose.layers.FillLayer
|
||||
import org.maplibre.compose.layers.LineLayer
|
||||
import org.maplibre.compose.map.MaplibreMap
|
||||
import org.maplibre.compose.sources.GeoJsonData
|
||||
import org.maplibre.compose.sources.rememberGeoJsonSource
|
||||
import org.maplibre.spatialk.geojson.Position
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private const val OPENFREEMAP_STYLE = "https://tiles.openfreemap.org/styles/liberty"
|
||||
|
||||
/**
|
||||
* Map screen — will display Mapbox map with zone polygons.
|
||||
* TODO: Integrate Mapbox SDK, render own + friend zones, live GPS track.
|
||||
*/
|
||||
@Composable
|
||||
fun MapScreen() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Map Screen\n(Mapbox integration pending)",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fun MapScreen(
|
||||
viewModel: MapViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val cameraState = rememberCameraState(
|
||||
firstPosition = CameraPosition(
|
||||
target = Position(latitude = 55.7558, longitude = 37.6173), // Moscow default
|
||||
zoom = 12.0,
|
||||
),
|
||||
)
|
||||
|
||||
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
|
||||
if (hasLocationPermission) {
|
||||
goToMyLocation(context, scope, cameraState)
|
||||
}
|
||||
}
|
||||
|
||||
// Build GeoJSON strings for own and friend zones
|
||||
val myZoneGeoJson = remember(state.myZones) {
|
||||
buildZoneGeoJson(state.myZones)
|
||||
}
|
||||
|
||||
val friendZoneGeoJson = remember(state.friendZones) {
|
||||
buildZoneGeoJson(state.friendZones)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
MaplibreMap(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
baseStyle = org.maplibre.compose.style.BaseStyle.Uri(OPENFREEMAP_STYLE),
|
||||
cameraState = cameraState,
|
||||
) {
|
||||
// Friend zones layer (rendered below own zones)
|
||||
if (friendZoneGeoJson.isNotEmpty()) {
|
||||
val friendSource = rememberGeoJsonSource(
|
||||
data = GeoJsonData.JsonString(friendZoneGeoJson),
|
||||
)
|
||||
|
||||
FillLayer(
|
||||
id = "friend-zones-fill",
|
||||
source = friendSource,
|
||||
color = const(Color(0xFF2196F3)),
|
||||
opacity = const(0.25f),
|
||||
)
|
||||
LineLayer(
|
||||
id = "friend-zones-outline",
|
||||
source = friendSource,
|
||||
color = const(Color(0xFF1976D2)),
|
||||
width = const(2.dp),
|
||||
opacity = const(0.7f),
|
||||
)
|
||||
}
|
||||
|
||||
// Own zones layer (on top)
|
||||
if (myZoneGeoJson.isNotEmpty()) {
|
||||
val mySource = rememberGeoJsonSource(
|
||||
data = GeoJsonData.JsonString(myZoneGeoJson),
|
||||
)
|
||||
|
||||
FillLayer(
|
||||
id = "my-zones-fill",
|
||||
source = mySource,
|
||||
color = const(Color(0xFF4CAF50)),
|
||||
opacity = const(0.35f),
|
||||
)
|
||||
LineLayer(
|
||||
id = "my-zones-outline",
|
||||
source = mySource,
|
||||
color = const(Color(0xFF2E7D32)),
|
||||
width = const(3.dp),
|
||||
opacity = const(0.8f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Loading indicator
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Error message
|
||||
state.error?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// FABs column (top-end)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 16.dp, end = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// My Location button
|
||||
SmallFloatingActionButton(
|
||||
onClick = {
|
||||
if (hasLocationPermission) {
|
||||
goToMyLocation(context, scope, cameraState)
|
||||
} else {
|
||||
permissionLauncher.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Icon(Icons.Default.MyLocation, contentDescription = "My location")
|
||||
}
|
||||
|
||||
// Refresh zones button
|
||||
SmallFloatingActionButton(
|
||||
onClick = { viewModel.loadZones() },
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh zones")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current location and animates the camera to it.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun goToMyLocation(
|
||||
context: android.content.Context,
|
||||
scope: kotlinx.coroutines.CoroutineScope,
|
||||
cameraState: org.maplibre.compose.camera.CameraState,
|
||||
) {
|
||||
val fusedClient = LocationServices.getFusedLocationProviderClient(context)
|
||||
val cancellationToken = CancellationTokenSource()
|
||||
|
||||
fusedClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, cancellationToken.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (location != null) {
|
||||
scope.launch {
|
||||
cameraState.animateTo(
|
||||
finalPosition = CameraPosition(
|
||||
target = Position(
|
||||
latitude = location.latitude,
|
||||
longitude = location.longitude,
|
||||
),
|
||||
zoom = 15.0,
|
||||
),
|
||||
duration = 1.seconds,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a GeoJSON FeatureCollection string from zone responses.
|
||||
*/
|
||||
private fun buildZoneGeoJson(zones: List<ZoneBriefResponse>): String {
|
||||
if (zones.isEmpty()) return ""
|
||||
|
||||
val features = zones.mapNotNull { zone ->
|
||||
try {
|
||||
val polygon = zone.polygonGeojson.toString()
|
||||
"""{"type":"Feature","geometry":$polygon,"properties":{"zone_id":"${zone.id}","owner_id":"${zone.ownerId}","area_m2":${zone.areaM2},"defense_level":${zone.defenseLevel}}}"""
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}"""
|
||||
}
|
||||
|
||||
52
app/src/main/java/com/geozoner/app/ui/map/MapViewModel.kt
Normal file
52
app/src/main/java/com/geozoner/app/ui/map/MapViewModel.kt
Normal file
@@ -0,0 +1,52 @@
|
||||
package com.geozoner.app.ui.map
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.geozoner.app.data.remote.api.ZoneApi
|
||||
import com.geozoner.app.data.remote.dto.ZoneBriefResponse
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class MapUiState(
|
||||
val myZones: List<ZoneBriefResponse> = emptyList(),
|
||||
val friendZones: List<ZoneBriefResponse> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class MapViewModel @Inject constructor(
|
||||
private val zoneApi: ZoneApi,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(MapUiState())
|
||||
val uiState: StateFlow<MapUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadZones()
|
||||
}
|
||||
|
||||
fun loadZones() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val myZones = zoneApi.getMyZones()
|
||||
val friendZones = zoneApi.getFriendZones()
|
||||
_uiState.value = MapUiState(
|
||||
myZones = myZones,
|
||||
friendZones = friendZones,
|
||||
isLoading = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,11 @@ okhttp = "4.12.0"
|
||||
kotlinxSerialization = "1.7.3"
|
||||
retrofitKotlinxSerialization = "1.0.0"
|
||||
|
||||
# Mapbox
|
||||
mapbox = "11.8.2"
|
||||
# MapLibre
|
||||
maplibreCompose = "0.12.1"
|
||||
|
||||
# Google Play Services
|
||||
playServicesLocation = "21.3.0"
|
||||
|
||||
# Firebase
|
||||
firebaseBom = "33.7.0"
|
||||
@@ -85,8 +88,11 @@ okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor",
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||
retrofit-kotlinx-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerialization" }
|
||||
|
||||
# Mapbox
|
||||
mapbox-maps = { group = "com.mapbox.maps", name = "android", version.ref = "mapbox" }
|
||||
# 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" }
|
||||
|
||||
@@ -17,16 +17,6 @@ dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven {
|
||||
url = uri("https://api.mapbox.com/downloads/v2/releases/maven")
|
||||
credentials.username = "mapbox"
|
||||
credentials.password = providers.gradleProperty("MAPBOX_DOWNLOADS_TOKEN")
|
||||
.orElse(providers.environmentVariable("MAPBOX_DOWNLOADS_TOKEN"))
|
||||
.getOrElse("")
|
||||
authentication {
|
||||
create<BasicAuthentication>("basic")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user