mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Modularize more maps files (#3262)
This commit is contained in:
parent
bd0812f0d7
commit
7593560bba
21 changed files with 63 additions and 64 deletions
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue