diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d9feb98..a89561f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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,8 @@ 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) // Firebase (uncomment when google-services.json is added) // implementation(platform(libs.firebase.bom)) diff --git a/app/src/main/java/com/geozoner/app/ui/map/MapScreen.kt b/app/src/main/java/com/geozoner/app/ui/map/MapScreen.kt index 44af90d..7872c40 100644 --- a/app/src/main/java/com/geozoner/app/ui/map/MapScreen.kt +++ b/app/src/main/java/com/geozoner/app/ui/map/MapScreen.kt @@ -2,25 +2,147 @@ package com.geozoner.app.ui.map import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +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.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.geozoner.app.data.remote.dto.ZoneBriefResponse +import com.geozoner.app.ui.theme.ZoneColors +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 + +private const val OPENFREEMAP_STYLE = "https://tiles.openfreemap.org/styles/liberty" -/** - * Map screen — will display Mapbox map with zone polygons. - * TODO: Integrate Mapbox SDK, render own + friend zones, live GPS track. - */ @Composable -fun MapScreen() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text( - text = "Map Screen\n(Mapbox integration pending)", - style = MaterialTheme.typography.headlineMedium, - ) +fun MapScreen( + viewModel: MapViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsState() + + // 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), + ) { + // 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), + ) + } + + // Refresh button + SmallFloatingActionButton( + onClick = { viewModel.loadZones() }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 16.dp, end = 16.dp), + containerColor = MaterialTheme.colorScheme.surface, + ) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh zones") + } } } + +/** + * Builds a GeoJSON FeatureCollection string from zone responses. + * Each zone becomes a Feature with a Polygon geometry. + */ +private fun buildZoneGeoJson(zones: List): 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(",")}]}""" +} diff --git a/app/src/main/java/com/geozoner/app/ui/map/MapViewModel.kt b/app/src/main/java/com/geozoner/app/ui/map/MapViewModel.kt new file mode 100644 index 0000000..71f9854 --- /dev/null +++ b/app/src/main/java/com/geozoner/app/ui/map/MapViewModel.kt @@ -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 = emptyList(), + val friendZones: List = 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 = _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, + ) + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d7160d8..7c284e8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,8 +27,8 @@ okhttp = "4.12.0" kotlinxSerialization = "1.7.3" retrofitKotlinxSerialization = "1.0.0" -# Mapbox -mapbox = "11.8.2" +# MapLibre +maplibreCompose = "0.12.1" # Firebase firebaseBom = "33.7.0" @@ -85,8 +85,8 @@ 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" } # Firebase firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 93236ba..9a87ae2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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("basic") - } - } } }