Modularize more maps files (#3262)

This commit is contained in:
Phil Oliver 2025-09-30 21:37:17 -04:00 committed by GitHub
parent bd0812f0d7
commit 7593560bba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 63 additions and 64 deletions

View file

@ -1,34 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object MapModule {
// Serialization Provider (from original SerializationModule)
@Provides @Singleton
fun provideJson(): Json = Json { prettyPrint = false }
}

View file

@ -1,140 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.map
import android.Manifest
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import com.google.android.gms.common.api.ResolvableApiException
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.LocationSettingsRequest
import com.google.android.gms.location.Priority
import timber.log.Timber
private const val INTERVAL_MILLIS = 10000L
@Suppress("LongMethod")
@Composable
fun LocationPermissionsHandler(onPermissionResult: (Boolean) -> Unit) {
val context = LocalContext.current
var localHasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED,
)
}
val requestLocationPermissionLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { isGranted ->
localHasPermission = isGranted
// Defer to the LaunchedEffect(localHasPermission) to check settings before confirming via
// onPermissionResult
// if permission is granted. If not granted, immediately report false.
if (!isGranted) {
onPermissionResult(false)
}
}
val locationSettingsLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartIntentSenderForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
Timber.d("Location settings changed by user.")
// User has enabled location services or improved accuracy.
onPermissionResult(true) // Settings are now adequate, and permission was already granted.
} else {
Timber.d("Location settings change cancelled by user.")
// User chose not to change settings. The permission itself is still granted,
// but the experience might be degraded. For the purpose of enabling map features,
// we consider this as success if the core permission is there.
// If stricter handling is needed (e.g., block feature if settings not optimal),
// this logic might change.
onPermissionResult(localHasPermission)
}
}
LaunchedEffect(Unit) {
// Initial permission check
when (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) {
PackageManager.PERMISSION_GRANTED -> {
if (!localHasPermission) {
localHasPermission = true
}
// If permission is already granted, proceed to check location settings.
// The LaunchedEffect(localHasPermission) will handle this.
// No need to call onPermissionResult(true) here yet, let settings check complete.
}
else -> {
// Request permission if not granted. The launcher's callback will update localHasPermission.
requestLocationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
}
LaunchedEffect(localHasPermission) {
// Handles logic after permission status is known/updated
if (localHasPermission) {
// Permission is granted, now check location settings
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, INTERVAL_MILLIS).build()
val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest)
val client = LocationServices.getSettingsClient(context)
val task = client.checkLocationSettings(builder.build())
task.addOnSuccessListener {
Timber.d("Location settings are satisfied.")
onPermissionResult(true) // Permission granted and settings are good
}
task.addOnFailureListener { exception ->
if (exception is ResolvableApiException) {
try {
val intentSenderRequest = IntentSenderRequest.Builder(exception.resolution).build()
locationSettingsLauncher.launch(intentSenderRequest)
// Result of this launch will be handled by locationSettingsLauncher's callback
} catch (sendEx: ActivityNotFoundException) {
Timber.d("Error launching location settings resolution ${sendEx.message}.")
onPermissionResult(true) // Permission is granted, but settings dialog failed. Proceed.
}
} else {
Timber.d("Location settings are not satisfiable.${exception.message}")
onPermissionResult(true) // Permission is granted, but settings not ideal. Proceed.
}
}
} else {
// If permission is not granted, report false.
// This case is primarily handled by the requestLocationPermissionLauncher's callback
// if the initial state was denied, or if user denies it.
onPermissionResult(false)
}
}
}

View file

@ -68,10 +68,7 @@ import com.geeksville.mesh.MeshProtos.Position
import com.geeksville.mesh.MeshProtos.Waypoint
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.map.components.ClusterItemsListDialog
import com.geeksville.mesh.ui.map.components.CustomMapLayersSheet
import com.geeksville.mesh.ui.map.components.CustomTileProviderManagerSheet
import com.geeksville.mesh.ui.map.components.EditWaypointDialog
import com.geeksville.mesh.ui.map.components.MapControlsOverlay
import com.geeksville.mesh.ui.map.components.NodeClusterMarkers
import com.geeksville.mesh.ui.map.components.WaypointMarkers
import com.geeksville.mesh.ui.metrics.HEADING_DEG
@ -115,6 +112,13 @@ import org.meshtastic.core.model.util.mpsToKmph
import org.meshtastic.core.model.util.mpsToMph
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.strings.R
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.LayerType
import org.meshtastic.feature.map.LocationPermissionsHandler
import org.meshtastic.feature.map.MapViewModel
import org.meshtastic.feature.map.component.CustomMapLayersSheet
import org.meshtastic.feature.map.component.CustomTileProviderManagerSheet
import org.meshtastic.feature.map.component.MapControlsOverlay
import timber.log.Timber
import java.text.DateFormat

View file

@ -1,518 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.map
import android.app.Application
import android.net.Uri
import androidx.core.net.toFile
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.ConfigProtos
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.TileProvider
import com.google.android.gms.maps.model.UrlTileProvider
import com.google.maps.android.compose.MapType
import com.google.maps.android.data.geojson.GeoJsonLayer
import com.google.maps.android.data.kml.KmlLayer
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.json.JSONObject
import org.meshtastic.core.data.model.CustomTileProviderConfig
import org.meshtastic.core.data.repository.CustomTileProviderRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.prefs.map.GoogleMapsPrefs
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.service.ServiceRepository
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.MalformedURLException
import java.net.URL
import java.util.UUID
import javax.inject.Inject
private const val TILE_SIZE = 256
@Serializable
data class MapCameraPosition(
val targetLat: Double,
val targetLng: Double,
val zoom: Float,
val tilt: Float,
val bearing: Float,
)
@Suppress("TooManyFunctions", "LongParameterList")
@HiltViewModel
class MapViewModel
@Inject
constructor(
private val application: Application,
mapPrefs: MapPrefs,
private val googleMapsPrefs: GoogleMapsPrefs,
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioConfigRepository: RadioConfigRepository,
serviceRepository: ServiceRepository,
private val customTileProviderRepository: CustomTileProviderRepository,
uiPreferencesDataSource: UiPreferencesDataSource,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, serviceRepository) {
val theme: StateFlow<Int> = uiPreferencesDataSource.theme
private val _errorFlow = MutableSharedFlow<String>()
val errorFlow: SharedFlow<String> = _errorFlow.asSharedFlow()
val customTileProviderConfigs: StateFlow<List<CustomTileProviderConfig>> =
customTileProviderRepository
.getCustomTileProviders()
.stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = emptyList())
private val _selectedCustomTileProviderUrl = MutableStateFlow<String?>(null)
val selectedCustomTileProviderUrl: StateFlow<String?> = _selectedCustomTileProviderUrl.asStateFlow()
private val _selectedGoogleMapType = MutableStateFlow(MapType.NORMAL)
val selectedGoogleMapType: StateFlow<MapType> = _selectedGoogleMapType.asStateFlow()
val displayUnits =
radioConfigRepository.deviceProfileFlow
.mapNotNull { it.config.display.units }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC,
)
fun addCustomTileProvider(name: String, urlTemplate: String) {
viewModelScope.launch {
if (name.isBlank() || urlTemplate.isBlank() || !isValidTileUrlTemplate(urlTemplate)) {
_errorFlow.emit("Invalid name or URL template for custom tile provider.")
return@launch
}
if (customTileProviderConfigs.value.any { it.name.equals(name, ignoreCase = true) }) {
_errorFlow.emit("Custom tile provider with name '$name' already exists.")
return@launch
}
val newConfig = CustomTileProviderConfig(name = name, urlTemplate = urlTemplate)
customTileProviderRepository.addCustomTileProvider(newConfig)
}
}
fun updateCustomTileProvider(configToUpdate: CustomTileProviderConfig) {
viewModelScope.launch {
if (
configToUpdate.name.isBlank() ||
configToUpdate.urlTemplate.isBlank() ||
!isValidTileUrlTemplate(configToUpdate.urlTemplate)
) {
_errorFlow.emit("Invalid name or URL template for updating custom tile provider.")
return@launch
}
val existingConfigs = customTileProviderConfigs.value
if (
existingConfigs.any {
it.id != configToUpdate.id && it.name.equals(configToUpdate.name, ignoreCase = true)
}
) {
_errorFlow.emit("Another custom tile provider with name '${configToUpdate.name}' already exists.")
return@launch
}
customTileProviderRepository.updateCustomTileProvider(configToUpdate)
val originalConfig = customTileProviderRepository.getCustomTileProviderById(configToUpdate.id)
if (
_selectedCustomTileProviderUrl.value != null &&
originalConfig?.urlTemplate == _selectedCustomTileProviderUrl.value
) {
// No change needed if URL didn't change, or handle if it did
} else if (originalConfig != null && _selectedCustomTileProviderUrl.value != originalConfig.urlTemplate) {
val currentlySelectedConfig =
customTileProviderConfigs.value.find { it.urlTemplate == _selectedCustomTileProviderUrl.value }
if (currentlySelectedConfig?.id == configToUpdate.id) {
_selectedCustomTileProviderUrl.value = configToUpdate.urlTemplate
}
}
}
}
fun removeCustomTileProvider(configId: String) {
viewModelScope.launch {
val configToRemove = customTileProviderRepository.getCustomTileProviderById(configId)
customTileProviderRepository.deleteCustomTileProvider(configId)
if (configToRemove != null && _selectedCustomTileProviderUrl.value == configToRemove.urlTemplate) {
_selectedCustomTileProviderUrl.value = null
// Also clear from prefs
googleMapsPrefs.selectedCustomTileUrl = null
}
}
}
fun selectCustomTileProvider(config: CustomTileProviderConfig?) {
if (config != null) {
if (!isValidTileUrlTemplate(config.urlTemplate)) {
Timber.tag("MapViewModel").w("Attempted to select invalid URL template: ${config.urlTemplate}")
_selectedCustomTileProviderUrl.value = null
googleMapsPrefs.selectedCustomTileUrl = null
return
}
_selectedCustomTileProviderUrl.value = config.urlTemplate
_selectedGoogleMapType.value = MapType.NORMAL // Reset to a default or keep last? For now, reset.
googleMapsPrefs.selectedCustomTileUrl = config.urlTemplate
googleMapsPrefs.selectedGoogleMapType = null
} else {
_selectedCustomTileProviderUrl.value = null
googleMapsPrefs.selectedCustomTileUrl = null
}
}
fun setSelectedGoogleMapType(mapType: MapType) {
_selectedGoogleMapType.value = mapType
_selectedCustomTileProviderUrl.value = null // Clear custom selection
googleMapsPrefs.selectedGoogleMapType = mapType.name
googleMapsPrefs.selectedCustomTileUrl = null
}
fun createUrlTileProvider(urlString: String): TileProvider? {
if (!isValidTileUrlTemplate(urlString)) {
Timber.tag("MapViewModel").e("Tile URL does not contain valid {x}, {y}, and {z} placeholders: $urlString")
return null
}
return object : UrlTileProvider(TILE_SIZE, TILE_SIZE) {
override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? {
val formattedUrl =
urlString
.replace("{z}", zoom.toString(), ignoreCase = true)
.replace("{x}", x.toString(), ignoreCase = true)
.replace("{y}", y.toString(), ignoreCase = true)
return try {
URL(formattedUrl)
} catch (e: MalformedURLException) {
Timber.tag("MapViewModel").e(e, "Malformed URL: $formattedUrl")
null
}
}
}
}
private fun isValidTileUrlTemplate(urlTemplate: String): Boolean = urlTemplate.contains("{z}", ignoreCase = true) &&
urlTemplate.contains("{x}", ignoreCase = true) &&
urlTemplate.contains("{y}", ignoreCase = true)
private val _mapLayers = MutableStateFlow<List<MapLayerItem>>(emptyList())
val mapLayers: StateFlow<List<MapLayerItem>> = _mapLayers.asStateFlow()
init {
viewModelScope.launch {
customTileProviderRepository.getCustomTileProviders().first()
loadPersistedMapType()
}
loadPersistedLayers()
}
private fun loadPersistedMapType() {
val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl
if (savedCustomUrl != null) {
// Check if this custom provider still exists
if (
customTileProviderConfigs.value.any { it.urlTemplate == savedCustomUrl } &&
isValidTileUrlTemplate(savedCustomUrl)
) {
_selectedCustomTileProviderUrl.value = savedCustomUrl
_selectedGoogleMapType.value = MapType.NORMAL // Default, as custom is active
} else {
// The saved custom URL is no longer valid or doesn't exist, remove preference
googleMapsPrefs.selectedCustomTileUrl = null
// Fallback to default Google Map type
_selectedGoogleMapType.value = MapType.NORMAL
}
} else {
val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType
try {
_selectedGoogleMapType.value = MapType.valueOf(savedGoogleMapTypeName ?: MapType.NORMAL.name)
} catch (e: IllegalArgumentException) {
Timber.e(e, "Invalid saved Google Map type: $savedGoogleMapTypeName")
_selectedGoogleMapType.value = MapType.NORMAL // Fallback in case of invalid stored name
googleMapsPrefs.selectedGoogleMapType = null
}
}
}
private fun loadPersistedLayers() {
viewModelScope.launch(Dispatchers.IO) {
try {
val layersDir = File(application.filesDir, "map_layers")
if (layersDir.exists() && layersDir.isDirectory) {
val persistedLayerFiles = layersDir.listFiles()
if (persistedLayerFiles != null) {
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls
val loadedItems =
persistedLayerFiles.mapNotNull { file ->
if (file.isFile) {
val layerType =
when (file.extension.lowercase()) {
"kml",
"kmz",
-> LayerType.KML
"geojson",
"json",
-> LayerType.GEOJSON
else -> null
}
layerType?.let {
val uri = Uri.fromFile(file)
MapLayerItem(
name = file.nameWithoutExtension,
uri = uri,
isVisible = !hiddenLayerUrls.contains(uri.toString()),
layerType = it,
)
}
} else {
null
}
}
_mapLayers.value = loadedItems
if (loadedItems.isNotEmpty()) {
Timber.tag("MapViewModel").i("Loaded ${loadedItems.size} persisted map layers.")
}
}
} else {
Timber.tag("MapViewModel").i("Map layers directory does not exist. No layers loaded.")
}
} catch (e: Exception) {
Timber.tag("MapViewModel").e(e, "Error loading persisted map layers")
_mapLayers.value = emptyList()
}
}
}
fun addMapLayer(uri: Uri, fileName: String?) {
viewModelScope.launch {
val layerName = fileName?.substringBeforeLast('.') ?: "Layer ${mapLayers.value.size + 1}"
val extension =
fileName?.substringAfterLast('.', "")?.lowercase()
?: application.contentResolver.getType(uri)?.split('/')?.last()
val kmlExtensions = listOf("kml", "kmz", "vnd.google-earth.kml+xml", "vnd.google-earth.kmz")
val geoJsonExtensions = listOf("geojson", "json")
val layerType =
when (extension) {
in kmlExtensions -> LayerType.KML
in geoJsonExtensions -> LayerType.GEOJSON
else -> null
}
if (layerType == null) {
Timber.tag("MapViewModel").e("Unsupported map layer file type: $extension")
return@launch
}
val finalFileName =
if (fileName != null) {
"$layerName.$extension"
} else {
"layer_${UUID.randomUUID()}.$extension"
}
val localFileUri = copyFileToInternalStorage(uri, finalFileName)
if (localFileUri != null) {
val newItem = MapLayerItem(name = layerName, uri = localFileUri, layerType = layerType)
_mapLayers.value = _mapLayers.value + newItem
} else {
Timber.tag("MapViewModel").e("Failed to copy file to internal storage.")
}
}
}
private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) {
try {
val inputStream = application.contentResolver.openInputStream(uri)
val directory = File(application.filesDir, "map_layers")
if (!directory.exists()) {
directory.mkdirs()
}
val outputFile = File(directory, fileName)
val outputStream = FileOutputStream(outputFile)
inputStream?.use { input -> outputStream.use { output -> input.copyTo(output) } }
Uri.fromFile(outputFile)
} catch (e: IOException) {
Timber.tag("MapViewModel").e(e, "Error copying file to internal storage")
null
}
}
fun toggleLayerVisibility(layerId: String) {
var toggledLayer: MapLayerItem? = null
val updatedLayers =
_mapLayers.value.map {
if (it.id == layerId) {
toggledLayer = it.copy(isVisible = !it.isVisible)
toggledLayer
} else {
it
}
}
_mapLayers.value = updatedLayers
toggledLayer?.let {
if (it.isVisible) {
googleMapsPrefs.hiddenLayerUrls -= it.uri.toString()
} else {
googleMapsPrefs.hiddenLayerUrls += it.uri.toString()
}
}
}
fun removeMapLayer(layerId: String) {
viewModelScope.launch {
val layerToRemove = _mapLayers.value.find { it.id == layerId }
when (layerToRemove?.layerType) {
LayerType.KML -> layerToRemove.kmlLayerData?.removeLayerFromMap()
LayerType.GEOJSON -> layerToRemove.geoJsonLayerData?.removeLayerFromMap()
null -> {}
}
layerToRemove?.uri?.let { uri ->
deleteFileToInternalStorage(uri)
googleMapsPrefs.hiddenLayerUrls -= uri.toString()
}
_mapLayers.value = _mapLayers.value.filterNot { it.id == layerId }
}
}
private suspend fun deleteFileToInternalStorage(uri: Uri) {
withContext(Dispatchers.IO) {
try {
val file = uri.toFile()
if (file.exists()) {
file.delete()
}
} catch (e: Exception) {
Timber.tag("MapViewModel").e(e, "Error deleting file from internal storage")
}
}
}
@Suppress("Recycle")
private suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
val uriToLoad = layerItem.uri ?: return null
return withContext(Dispatchers.IO) {
try {
application.contentResolver.openInputStream(uriToLoad)
} catch (_: Exception) {
Timber.d("MapViewModel: Error opening InputStream from URI: $uriToLoad")
null
}
}
}
suspend fun loadMapLayerIfNeeded(map: GoogleMap, layerItem: MapLayerItem) {
if (layerItem.kmlLayerData != null || layerItem.geoJsonLayerData != null) return
try {
when (layerItem.layerType) {
LayerType.KML -> loadKmlLayerIfNeeded(layerItem, map)
LayerType.GEOJSON -> loadGeoJsonLayerIfNeeded(layerItem, map)
}
} catch (e: Exception) {
Timber.tag("MapViewModel").e(e, "Error loading map layer for ${layerItem.uri}")
}
}
private suspend fun loadKmlLayerIfNeeded(layerItem: MapLayerItem, map: GoogleMap) {
val kmlLayer =
getInputStreamFromUri(layerItem)?.use {
KmlLayer(map, it, application.applicationContext).apply {
if (!layerItem.isVisible) removeLayerFromMap()
}
}
_mapLayers.update { currentLayers ->
currentLayers.map {
if (it.id == layerItem.id) {
it.copy(kmlLayerData = kmlLayer)
} else {
it
}
}
}
}
private suspend fun loadGeoJsonLayerIfNeeded(layerItem: MapLayerItem, map: GoogleMap) {
val geoJsonLayer =
getInputStreamFromUri(layerItem)?.use { inputStream ->
val jsonObject = JSONObject(inputStream.bufferedReader().use { it.readText() })
GeoJsonLayer(map, jsonObject).apply { if (!layerItem.isVisible) removeLayerFromMap() }
}
_mapLayers.update { currentLayers ->
currentLayers.map {
if (it.id == layerItem.id) {
it.copy(geoJsonLayerData = geoJsonLayer)
} else {
it
}
}
}
}
fun clearLoadedLayerData() {
_mapLayers.update { currentLayers ->
currentLayers.map { it.copy(kmlLayerData = null, geoJsonLayerData = null) }
}
}
}
enum class LayerType {
KML,
GEOJSON,
}
data class MapLayerItem(
val id: String = UUID.randomUUID().toString(),
val name: String,
val uri: Uri? = null,
var isVisible: Boolean = true,
var kmlLayerData: KmlLayer? = null,
var geoJsonLayerData: GeoJsonLayer? = null,
val layerType: LayerType,
)

View file

@ -1,121 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.map.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ui.map.MapLayerItem
import org.meshtastic.core.strings.R
@Suppress("LongMethod")
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun CustomMapLayersSheet(
mapLayers: List<MapLayerItem>,
onToggleVisibility: (String) -> Unit,
onRemoveLayer: (String) -> Unit,
onAddLayerClicked: () -> Unit,
) {
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
item {
Text(
modifier = Modifier.Companion.padding(16.dp),
text = stringResource(R.string.manage_map_layers),
style = MaterialTheme.typography.headlineSmall,
)
HorizontalDivider()
}
item {
Text(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 0.dp),
text = stringResource(R.string.map_layer_formats),
style = MaterialTheme.typography.bodySmall,
)
}
if (mapLayers.isEmpty()) {
item {
Text(
modifier = Modifier.Companion.padding(16.dp),
text = stringResource(R.string.no_map_layers_loaded),
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
items(mapLayers, key = { it.id }) { layer ->
ListItem(
headlineContent = { Text(layer.name) },
trailingContent = {
Row {
IconButton(onClick = { onToggleVisibility(layer.id) }) {
Icon(
imageVector =
if (layer.isVisible) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
},
contentDescription =
stringResource(
if (layer.isVisible) {
R.string.hide_layer
} else {
R.string.show_layer
},
),
)
}
IconButton(onClick = { onRemoveLayer(layer.id) }) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = stringResource(R.string.remove_layer),
)
}
}
},
)
HorizontalDivider()
}
}
item {
Button(modifier = Modifier.Companion.fillMaxWidth().padding(16.dp), onClick = onAddLayerClicked) {
Text(stringResource(R.string.add_layer))
}
}
}
}

View file

@ -1,258 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.map.components
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.ui.map.MapViewModel
import kotlinx.coroutines.flow.collectLatest
import org.meshtastic.core.data.model.CustomTileProviderConfig
import org.meshtastic.core.strings.R
@Suppress("LongMethod")
@Composable
fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
var editingConfig by remember { mutableStateOf<CustomTileProviderConfig?>(null) }
var showEditDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
LaunchedEffect(Unit) {
mapViewModel.errorFlow.collectLatest { errorMessage ->
Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show()
}
}
if (showEditDialog) {
AddEditCustomTileProviderDialog(
config = editingConfig,
onDismiss = { showEditDialog = false },
onSave = { name, url ->
if (editingConfig == null) { // Adding new
mapViewModel.addCustomTileProvider(name, url)
} else { // Editing existing
mapViewModel.updateCustomTileProvider(editingConfig!!.copy(name = name, urlTemplate = url))
}
showEditDialog = false
},
mapViewModel = mapViewModel,
)
}
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
item {
Text(
text = stringResource(R.string.manage_custom_tile_sources),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(16.dp),
)
HorizontalDivider()
}
if (customTileProviders.isEmpty()) {
item {
Text(
text = stringResource(R.string.no_custom_tile_sources_found),
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
items(customTileProviders, key = { it.id }) { config ->
ListItem(
headlineContent = { Text(config.name) },
supportingContent = { Text(config.urlTemplate, style = MaterialTheme.typography.bodySmall) },
trailingContent = {
Row {
IconButton(
onClick = {
editingConfig = config
showEditDialog = true
},
) {
Icon(
Icons.Filled.Edit,
contentDescription = stringResource(R.string.edit_custom_tile_source),
)
}
IconButton(onClick = { mapViewModel.removeCustomTileProvider(config.id) }) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.delete_custom_tile_source),
)
}
}
},
)
HorizontalDivider()
}
}
item {
Button(
onClick = {
editingConfig = null
showEditDialog = true
},
modifier = Modifier.fillMaxWidth().padding(16.dp),
) {
Text(stringResource(R.string.add_custom_tile_source))
}
}
}
}
@Suppress("LongMethod")
@Composable
private fun AddEditCustomTileProviderDialog(
config: CustomTileProviderConfig?,
onDismiss: () -> Unit,
onSave: (String, String) -> Unit,
mapViewModel: MapViewModel,
) {
var name by rememberSaveable { mutableStateOf(config?.name ?: "") }
var url by rememberSaveable { mutableStateOf(config?.urlTemplate ?: "") }
var nameError by remember { mutableStateOf<String?>(null) }
var urlError by remember { mutableStateOf<String?>(null) }
val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
val emptyNameError = stringResource(R.string.name_cannot_be_empty)
val providerNameExistsError = stringResource(R.string.provider_name_exists)
val urlCannotBeEmptyError = stringResource(R.string.url_cannot_be_empty)
val urlMustContainPlaceholdersError = stringResource(R.string.url_must_contain_placeholders)
fun validateAndSave() {
val currentNameError =
validateName(name, customTileProviders, config?.id, emptyNameError, providerNameExistsError)
val currentUrlError = validateUrl(url, urlCannotBeEmptyError, urlMustContainPlaceholdersError)
nameError = currentNameError
urlError = currentUrlError
if (currentNameError == null && currentUrlError == null) {
onSave(name, url)
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
if (config == null) {
stringResource(R.string.add_custom_tile_source)
} else {
stringResource(R.string.edit_custom_tile_source)
},
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = name,
onValueChange = {
name = it
nameError = null
},
label = { Text(stringResource(R.string.name)) },
isError = nameError != null,
supportingText = { nameError?.let { Text(it) } },
singleLine = true,
)
OutlinedTextField(
value = url,
onValueChange = {
url = it
urlError = null
},
label = { Text(stringResource(R.string.url_template)) },
isError = urlError != null,
supportingText = {
if (urlError != null) {
Text(urlError!!)
} else {
Text(stringResource(R.string.url_template_hint))
}
},
singleLine = false,
maxLines = 2,
)
}
},
confirmButton = { Button(onClick = { validateAndSave() }) { Text(stringResource(R.string.save)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}
private fun validateName(
name: String,
providers: List<CustomTileProviderConfig>,
currentId: String?,
emptyNameError: String,
nameExistsError: String,
): String? = if (name.isBlank()) {
emptyNameError
} else if (providers.any { it.name.equals(name, ignoreCase = true) && it.id != currentId }) {
nameExistsError
} else {
null
}
private fun validateUrl(url: String, emptyUrlError: String, mustContainPlaceholdersError: String): String? =
if (url.isBlank()) {
emptyUrlError
} else if (
!url.contains("{z}", ignoreCase = true) ||
!url.contains("{x}", ignoreCase = true) ||
!url.contains("{y}", ignoreCase = true)
) {
mustContainPlaceholdersError
} else {
null
}

View file

@ -1,43 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.map.components
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun MapButton(
modifier: Modifier = Modifier,
icon: ImageVector,
iconTint: Color? = null,
contentDescription: String,
onClick: () -> Unit,
) {
FilledIconButton(onClick = onClick, modifier = modifier) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = iconTint ?: IconButtonDefaults.filledIconButtonColors().contentColor,
)
}
}

View file

@ -1,143 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.map.components
import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationDisabled
import androidx.compose.material.icons.filled.Navigation
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.Map
import androidx.compose.material.icons.outlined.MyLocation
import androidx.compose.material.icons.outlined.Navigation
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalFloatingToolbar
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.stringResource
import com.geeksville.mesh.ui.map.MapViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MapControlsOverlay(
modifier: Modifier = Modifier,
mapFilterMenuExpanded: Boolean,
onMapFilterMenuDismissRequest: () -> Unit,
onToggleMapFilterMenu: () -> Unit,
mapViewModel: MapViewModel, // For MapFilterDropdown and MapTypeDropdown
mapTypeMenuExpanded: Boolean,
onMapTypeMenuDismissRequest: () -> Unit,
onToggleMapTypeMenu: () -> Unit,
onManageLayersClicked: () -> Unit,
onManageCustomTileProvidersClicked: () -> Unit, // New parameter
isNodeMap: Boolean,
// Location tracking parameters
hasLocationPermission: Boolean = false,
isLocationTrackingEnabled: Boolean = false,
onToggleLocationTracking: () -> Unit = {},
bearing: Float = 0f,
onCompassClick: () -> Unit = {},
followPhoneBearing: Boolean,
) {
HorizontalFloatingToolbar(
modifier = modifier,
expanded = true,
leadingContent = {},
trailingContent = {},
content = {
CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
if (isNodeMap) {
MapButton(
icon = Icons.Outlined.Tune,
contentDescription = stringResource(id = R.string.map_filter),
onClick = onToggleMapFilterMenu,
)
NodeMapFilterDropdown(
expanded = mapFilterMenuExpanded,
onDismissRequest = onMapFilterMenuDismissRequest,
mapViewModel = mapViewModel,
)
} else {
Box {
MapButton(
icon = Icons.Outlined.Tune,
contentDescription = stringResource(id = R.string.map_filter),
onClick = onToggleMapFilterMenu,
)
MapFilterDropdown(
expanded = mapFilterMenuExpanded,
onDismissRequest = onMapFilterMenuDismissRequest,
mapViewModel = mapViewModel,
)
}
}
Box {
MapButton(
icon = Icons.Outlined.Map,
contentDescription = stringResource(id = R.string.map_tile_source),
onClick = onToggleMapTypeMenu,
)
MapTypeDropdown(
expanded = mapTypeMenuExpanded,
onDismissRequest = onMapTypeMenuDismissRequest,
mapViewModel = mapViewModel, // Pass mapViewModel
onManageCustomTileProvidersClicked = onManageCustomTileProvidersClicked, // Pass new callback
)
}
MapButton(
icon = Icons.Outlined.Layers,
contentDescription = stringResource(id = R.string.manage_map_layers),
onClick = onManageLayersClicked,
)
// Location tracking button
if (hasLocationPermission) {
MapButton(
icon =
if (isLocationTrackingEnabled) {
Icons.Default.LocationDisabled
} else {
Icons.Outlined.MyLocation
},
contentDescription = stringResource(id = R.string.toggle_my_position),
onClick = onToggleLocationTracking,
)
}
},
)
}
@Composable
private fun CompassButton(onClick: () -> Unit, bearing: Float, isFollowing: Boolean) {
val icon = if (isFollowing) Icons.Filled.Navigation else Icons.Outlined.Navigation
MapButton(
modifier = Modifier.rotate(-bearing),
icon = icon,
iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { bearing == 0f },
contentDescription = stringResource(id = R.string.orient_north),
onClick = onClick,
)
}

View file

@ -1,154 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.map.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.RadioButtonUnchecked
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.ui.map.LastHeardFilter
import com.geeksville.mesh.ui.map.MapViewModel
import org.meshtastic.core.strings.R
import kotlin.math.roundToInt
@Composable
internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) {
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
DropdownMenuItem(
text = { Text(stringResource(id = R.string.only_favorites)) },
onClick = { mapViewModel.toggleOnlyFavorites() },
leadingIcon = {
Icon(imageVector = Icons.Filled.Star, contentDescription = stringResource(id = R.string.only_favorites))
},
trailingIcon = {
Checkbox(
checked = mapFilterState.onlyFavorites,
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
)
},
)
DropdownMenuItem(
text = { Text(stringResource(id = R.string.show_waypoints)) },
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Place,
contentDescription = stringResource(id = R.string.show_waypoints),
)
},
trailingIcon = {
Checkbox(
checked = mapFilterState.showWaypoints,
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
)
},
)
DropdownMenuItem(
text = { Text(stringResource(id = R.string.show_precision_circle)) },
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.RadioButtonUnchecked, // Placeholder icon
contentDescription = stringResource(id = R.string.show_precision_circle),
)
},
trailingIcon = {
Checkbox(
checked = mapFilterState.showPrecisionCircle,
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
)
},
)
HorizontalDivider()
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter)
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
Text(
text =
stringResource(
R.string.last_heard_filter_label,
stringResource(mapFilterState.lastHeardFilter.label),
),
style = MaterialTheme.typography.labelLarge,
)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = {
val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
mapViewModel.setLastHeardFilter(filterOptions[newIndex])
},
valueRange = 0f..(filterOptions.size - 1).toFloat(),
steps = filterOptions.size - 2,
)
}
}
}
@Composable
internal fun NodeMapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) {
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardTrackFilter)
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
Text(
text =
stringResource(
R.string.last_heard_filter_label,
stringResource(mapFilterState.lastHeardTrackFilter.label),
),
style = MaterialTheme.typography.labelLarge,
)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = {
val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
mapViewModel.setLastHeardTrackFilter(filterOptions[newIndex])
},
valueRange = 0f..(filterOptions.size - 1).toFloat(),
steps = filterOptions.size - 2,
)
}
}
}

View file

@ -1,104 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.map.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.ui.map.MapViewModel
import com.google.maps.android.compose.MapType
import org.meshtastic.core.strings.R
@Suppress("LongMethod")
@Composable
internal fun MapTypeDropdown(
expanded: Boolean,
onDismissRequest: () -> Unit,
mapViewModel: MapViewModel,
onManageCustomTileProvidersClicked: () -> Unit,
) {
val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
val selectedCustomUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
val googleMapTypes =
listOf(
stringResource(id = R.string.map_type_normal) to MapType.NORMAL,
stringResource(id = R.string.map_type_satellite) to MapType.SATELLITE,
stringResource(id = R.string.map_type_terrain) to MapType.TERRAIN,
stringResource(id = R.string.map_type_hybrid) to MapType.HYBRID,
)
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
googleMapTypes.forEach { (name, type) ->
DropdownMenuItem(
text = { Text(name) },
onClick = {
mapViewModel.setSelectedGoogleMapType(type)
onDismissRequest() // Close menu
},
trailingIcon =
if (selectedCustomUrl == null && selectedGoogleMapType == type) {
{ Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.selected_map_type)) }
} else {
null
},
)
}
if (customTileProviders.isNotEmpty()) {
HorizontalDivider()
customTileProviders.forEach { config ->
DropdownMenuItem(
text = { Text(config.name) },
onClick = {
mapViewModel.selectCustomTileProvider(config)
onDismissRequest() // Close menu
},
trailingIcon =
if (selectedCustomUrl == config.urlTemplate) {
{
Icon(
Icons.Filled.Check,
contentDescription = stringResource(R.string.selected_map_type),
)
}
} else {
null
},
)
}
}
HorizontalDivider()
DropdownMenuItem(
text = { Text(stringResource(R.string.manage_custom_tile_sources)) },
onClick = {
onManageCustomTileProvidersClicked()
onDismissRequest()
},
)
}
}

View file

@ -20,7 +20,6 @@ package com.geeksville.mesh.ui.map.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.graphics.Color
import com.geeksville.mesh.ui.map.BaseMapViewModel
import com.geeksville.mesh.ui.map.NodeClusterItem
import com.geeksville.mesh.ui.node.components.NodeChip
import com.google.maps.android.clustering.Cluster
@ -28,6 +27,7 @@ import com.google.maps.android.clustering.view.DefaultClusterRenderer
import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.clustering.Clustering
import org.meshtastic.feature.map.BaseMapViewModel
@OptIn(MapsComposeExperimentalApi::class)
@Suppress("NestedBlockDepth")

View file

@ -21,13 +21,13 @@ import android.widget.Toast
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.ui.map.BaseMapViewModel
import com.geeksville.mesh.ui.node.DEG_D
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberUpdatedMarkerState
import org.meshtastic.core.strings.R
import org.meshtastic.feature.map.BaseMapViewModel
@Composable
fun WaypointMarkers(