Compare commits
4 Commits
4cc43a410b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
879582d5ee | ||
|
|
e2c825ed6f | ||
|
|
02171118ec | ||
|
|
ffc5d0887e |
@@ -30,13 +30,6 @@ android {
|
|||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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
|
// Backend base URL
|
||||||
buildConfigField(
|
buildConfigField(
|
||||||
"String",
|
"String",
|
||||||
@@ -121,8 +114,11 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.retrofit.kotlinx.serialization)
|
implementation(libs.retrofit.kotlinx.serialization)
|
||||||
|
|
||||||
// Mapbox (uncomment after setting MAPBOX_DOWNLOADS_TOKEN in local.properties)
|
// MapLibre (free, no API key needed)
|
||||||
// implementation(libs.mapbox.maps)
|
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))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +1,110 @@
|
|||||||
package com.geozoner.app.ui.friends
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.padding
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
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.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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
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.hilt.navigation.compose.hiltViewModel
|
||||||
|
import coil.compose.AsyncImage
|
||||||
import com.geozoner.app.R
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun FriendsScreen(
|
fun FriendsScreen(
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: FriendsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.friends_title)) },
|
title = { Text(stringResource(R.string.friends_title)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -36,17 +113,209 @@ fun FriendsScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
},
|
||||||
Column(
|
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
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(24.dp),
|
.padding(innerPadding),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
) {
|
||||||
|
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(
|
||||||
text = "Friends list will appear here",
|
text = "No friends yet",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
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
|
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.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.MaterialTheme
|
||||||
|
import androidx.compose.material3.SmallFloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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
|
@Composable
|
||||||
fun MapScreen() {
|
fun MapScreen(
|
||||||
Box(
|
viewModel: MapViewModel = hiltViewModel(),
|
||||||
modifier = Modifier.fillMaxSize(),
|
) {
|
||||||
contentAlignment = Alignment.Center,
|
val state by viewModel.uiState.collectAsState()
|
||||||
) {
|
val context = LocalContext.current
|
||||||
Text(
|
val scope = rememberCoroutineScope()
|
||||||
text = "Map Screen\n(Mapbox integration pending)",
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
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"
|
kotlinxSerialization = "1.7.3"
|
||||||
retrofitKotlinxSerialization = "1.0.0"
|
retrofitKotlinxSerialization = "1.0.0"
|
||||||
|
|
||||||
# Mapbox
|
# MapLibre
|
||||||
mapbox = "11.8.2"
|
maplibreCompose = "0.12.1"
|
||||||
|
|
||||||
|
# Google Play Services
|
||||||
|
playServicesLocation = "21.3.0"
|
||||||
|
|
||||||
# Firebase
|
# Firebase
|
||||||
firebaseBom = "33.7.0"
|
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" }
|
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" }
|
retrofit-kotlinx-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerialization" }
|
||||||
|
|
||||||
# Mapbox
|
# MapLibre
|
||||||
mapbox-maps = { group = "com.mapbox.maps", name = "android", version.ref = "mapbox" }
|
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" }
|
||||||
|
|||||||
@@ -17,16 +17,6 @@ dependencyResolutionManagement {
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
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