Compare commits

..

4 Commits

Author SHA1 Message Date
Redsandy
879582d5ee Implement Friends feature with ViewModel and UI enhancements
- Added FriendsViewModel to manage friends list, including loading, adding, and removing friends.
- Enhanced FriendsScreen UI with a dialog for adding friends, snackbar notifications for actions, and improved empty state handling.
- Integrated PullToRefresh functionality for refreshing the friends list.
- Updated dependencies and imports for Material3 components.
2026-03-15 02:23:09 +03:00
Redsandy
e2c825ed6f Enhance MapScreen with location permission handling and camera positioning
- Added functionality to request location permissions and navigate to the user's current location on the map.
- Integrated camera state management to animate the map view to the user's location.
- Updated UI to include a floating action button for accessing the user's location and refreshing zone data.
2026-03-15 01:49:37 +03:00
Redsandy
02171118ec Add GPS tracking functionality with location services integration
- Introduced TrackingRepository to manage activity tracking state and GPS points.
- Implemented LocationTrackingService for foreground GPS tracking using FusedLocationProviderClient.
- Created data models GpsPoint and TrackingState to represent tracking data.
- Enhanced TrackActivityScreen with UI for selecting activity type and displaying tracking status.
- Added LocationUtils for geospatial calculations, including distance tracking and loop closure detection.
- Updated build.gradle.kts to include Google Play Services Location dependency.
2026-03-15 00:12:08 +03:00
Redsandy
ffc5d0887e Refactor map integration to use MapLibre instead of Mapbox
- Removed Mapbox dependencies and related configurations from settings.gradle.kts and build.gradle.kts.
- Implemented MapViewModel to manage zone data and loading state.
- Updated MapScreen to render zones using MapLibre, including loading indicators and error handling.
- Added GeoJSON generation for zone data to be displayed on the map.
- Updated libraries version to use MapLibre Compose.
2026-03-14 23:58:27 +03:00
14 changed files with 1384 additions and 79 deletions

View File

@@ -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))

View File

@@ -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
}

View 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,
)

View File

@@ -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,
)

View File

@@ -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
}
}

View File

@@ -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)}")
} 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,
)
}
}

View File

@@ -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()
}
}

View File

@@ -1,33 +1,110 @@
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()) {
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(
title = { Text(stringResource(R.string.friends_title)) },
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
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
.padding(innerPadding),
) {
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 = "Friends list will appear here",
style = MaterialTheme.typography.bodyLarge,
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")
}
},
)
}

View File

@@ -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)
}
}

View File

@@ -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,
fun MapScreen(
viewModel: MapViewModel = hiltViewModel(),
) {
Text(
text = "Map Screen\n(Mapbox integration pending)",
style = MaterialTheme.typography.headlineMedium,
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(",")}]}"""
}

View 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,
)
}
}
}
}

View 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
}
}

View File

@@ -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" }

View File

@@ -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")
}
}
}
}