refactor: maps (#2097)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2025-08-13 12:51:19 -05:00
committed by GitHub
parent c05f434ff2
commit 87e50e03ea
76 changed files with 4188 additions and 1830 deletions

View File

@@ -41,7 +41,9 @@ class MeshUtilApplication : GeeksvilleApplication() {
crashlytics.setUserId(pref.getInstallId()) // be able to group all bugs per anonymous user
fun sendCrashReports() {
if (isAnalyticsAllowed) crashlytics.sendUnsentReports()
if (isAnalyticsAllowed) {
crashlytics.sendUnsentReports()
}
}
// Send any old reports if user approves

View File

@@ -21,23 +21,24 @@ import android.content.Context
import android.os.Bundle
import com.geeksville.mesh.android.AppPrefs
import com.geeksville.mesh.android.Logging
import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.analytics
import com.google.firebase.analytics.logEvent
import com.google.firebase.Firebase
class DataPair(val name: String, valueIn: Any?) {
val value = valueIn ?: "null"
/// An accumulating firebase event - only one allowed per event
// / An accumulating firebase event - only one allowed per event
constructor(d: Double) : this(FirebaseAnalytics.Param.VALUE, d)
constructor(d: Int) : this(FirebaseAnalytics.Param.VALUE, d)
}
/**
* Implement our analytics API using Firebase Analytics
*/
class FirebaseAnalytics(context: Context) : AnalyticsProvider, Logging {
/** Implement our analytics API using Firebase Analytics */
class FirebaseAnalytics(context: Context) :
AnalyticsProvider,
Logging {
val t = Firebase.analytics
@@ -85,12 +86,10 @@ class FirebaseAnalytics(context: Context) : AnalyticsProvider, Logging {
}
override fun increment(name: String, amount: Double) {
//Mint.logEvent("$name increment")
// Mint.logEvent("$name increment")
}
/**
* Send a google analytics screen view event
*/
/** Send a google analytics screen view event */
override fun sendScreenView(name: String) {
debug("Analytics: start screen $name")
t.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {

View File

@@ -57,7 +57,6 @@ import com.suddenh4x.ratingdialog.AppRating
import io.opentracing.util.GlobalTracer
import timber.log.Timber
/** Created by kevinh on 1/4/15. */
open class GeeksvilleApplication :
Application(),
Logging {
@@ -70,7 +69,9 @@ open class GeeksvilleApplication :
val isInTestLab: Boolean
get() {
val testLabSetting = Settings.System.getString(contentResolver, "firebase.test.lab")
if (testLabSetting != null) info("Testlab is $testLabSetting")
if (testLabSetting != null) {
info("Testlab is $testLabSetting")
}
return "true" == testLabSetting
}
@@ -109,6 +110,7 @@ open class GeeksvilleApplication :
fun askToRate(activity: AppCompatActivity) {
if (!isGooglePlayAvailable) return
@Suppress("MaxLineLength")
exceptionReporter {
// we don't want to crash our app because of bugs in this optional feature
AppRating.Builder(activity)

View File

@@ -0,0 +1,65 @@
/*
* 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 com.geeksville.mesh.repository.map.CustomTileProviderRepository
import com.geeksville.mesh.repository.map.SharedPreferencesCustomTileProviderRepository
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.json.Json
import javax.inject.Qualifier
import javax.inject.Singleton
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultDispatcher
@Module
@InstallIn(SingletonComponent::class)
object MapModule {
@Provides @DefaultDispatcher
fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
@Provides @IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
// Serialization Provider (from original SerializationModule)
@Provides @Singleton
fun provideJson(): Json = Json { prettyPrint = false }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class MapRepositoryModule {
@Binds
@Singleton
abstract fun bindCustomTileProviderRepository(
impl: SharedPreferencesCustomTileProviderRepository,
): CustomTileProviderRepository
}

View File

@@ -0,0 +1,27 @@
/*
* 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.model.map
class CustomTileSource {
companion object {
fun getTileSource(index: Int) {
index
}
}
}

View File

@@ -0,0 +1,33 @@
/*
* 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.repository.map
import com.geeksville.mesh.ui.map.CustomTileProviderConfig
import kotlinx.coroutines.flow.Flow
interface CustomTileProviderRepository {
fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>>
suspend fun addCustomTileProvider(config: CustomTileProviderConfig)
suspend fun updateCustomTileProvider(config: CustomTileProviderConfig)
suspend fun deleteCustomTileProvider(configId: String)
suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig?
}

View File

@@ -0,0 +1,104 @@
/*
* 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.repository.map
import android.content.Context
import androidx.core.content.edit
import com.geeksville.mesh.di.IoDispatcher
import com.geeksville.mesh.ui.map.CustomTileProviderConfig
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
private const val KEY_CUSTOM_TILE_PROVIDERS = "custom_tile_providers"
private const val PREFS_NAME_TILE = "map_tile_provider_prefs"
@Singleton
class SharedPreferencesCustomTileProviderRepository
@Inject
constructor(
@ApplicationContext private val context: Context,
private val json: Json,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : CustomTileProviderRepository {
private val sharedPreferences = context.getSharedPreferences(PREFS_NAME_TILE, Context.MODE_PRIVATE)
private val customTileProvidersStateFlow = MutableStateFlow<List<CustomTileProviderConfig>>(emptyList())
init {
loadDataFromPrefs()
}
private fun loadDataFromPrefs() {
val jsonString = sharedPreferences.getString(KEY_CUSTOM_TILE_PROVIDERS, null)
if (jsonString != null) {
try {
customTileProvidersStateFlow.value = json.decodeFromString<List<CustomTileProviderConfig>>(jsonString)
} catch (e: SerializationException) {
Timber.tag("TileRepo").e(e, "Error deserializing tile providers")
customTileProvidersStateFlow.value = emptyList()
}
} else {
customTileProvidersStateFlow.value = emptyList()
}
}
private suspend fun saveDataToPrefs(providers: List<CustomTileProviderConfig>) {
withContext(ioDispatcher) {
try {
val jsonString = json.encodeToString(providers)
sharedPreferences.edit { putString(KEY_CUSTOM_TILE_PROVIDERS, jsonString) }
} catch (e: SerializationException) {
Timber.tag("TileRepo").e(e, "Error serializing tile providers")
}
}
}
override fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>> =
customTileProvidersStateFlow.asStateFlow()
override suspend fun addCustomTileProvider(config: CustomTileProviderConfig) {
val newList = customTileProvidersStateFlow.value + config
customTileProvidersStateFlow.value = newList
saveDataToPrefs(newList)
}
override suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) {
val newList = customTileProvidersStateFlow.value.map { if (it.id == config.id) config else it }
customTileProvidersStateFlow.value = newList
saveDataToPrefs(newList)
}
override suspend fun deleteCustomTileProvider(configId: String) {
val newList = customTileProvidersStateFlow.value.filterNot { it.id == configId }
customTileProvidersStateFlow.value = newList
saveDataToPrefs(newList)
}
override suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? =
customTileProvidersStateFlow.value.find { it.id == configId }
}

View File

@@ -0,0 +1,140 @@
/*
* 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.geeksville.mesh.android.BuildUtils.debug
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
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) {
debug("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 {
debug("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 {
debug("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) {
debug("Error launching location settings resolution ${sendEx.message}.")
onPermissionResult(true) // Permission is granted, but settings dialog failed. Proceed.
}
} else {
debug("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

@@ -0,0 +1,701 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")
package com.geeksville.mesh.ui.map
import android.content.Intent
import android.graphics.Canvas
import android.graphics.Paint
import android.location.Location
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.animation.core.animate
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.filled.TripOrigin
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingToolbarDefaults
import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset
import androidx.compose.material3.FloatingToolbarExitDirection.Companion.End
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.rememberFloatingToolbarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.MeshProtos.Position
import com.geeksville.mesh.MeshProtos.Waypoint
import com.geeksville.mesh.R
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.BuildUtils.warn
import com.geeksville.mesh.copy
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
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
import com.geeksville.mesh.ui.metrics.formatPositionTime
import com.geeksville.mesh.ui.node.DEG_D
import com.geeksville.mesh.ui.node.components.NodeChip
import com.geeksville.mesh.util.formatAgo
import com.geeksville.mesh.util.metersIn
import com.geeksville.mesh.util.mpsToKmph
import com.geeksville.mesh.util.mpsToMph
import com.geeksville.mesh.util.toString
import com.geeksville.mesh.waypoint
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.BitmapDescriptorFactory
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.JointType
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
import com.google.android.gms.maps.model.RoundCap
import com.google.maps.android.clustering.ClusterItem
import com.google.maps.android.compose.CameraMoveStartedReason
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapEffect
import com.google.maps.android.compose.MapProperties
import com.google.maps.android.compose.MapType
import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.MarkerInfoWindowComposable
import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.TileOverlay
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
import com.google.maps.android.compose.widgets.DisappearingScaleBar
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import timber.log.Timber
import java.text.DateFormat
private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f
@Suppress("ReturnCount")
private fun filterNodeTrack(nodeTrack: List<Position>?): List<Position> {
if (nodeTrack.isNullOrEmpty()) return emptyList()
val sortedTrack = nodeTrack.sortedBy { it.time }
if (sortedTrack.size <= 2) return sortedTrack.map { it }
val filteredPoints = mutableListOf<MeshProtos.Position>()
var lastAddedPointProto = sortedTrack.first()
filteredPoints.add(lastAddedPointProto)
for (i in 1 until sortedTrack.size - 1) {
val currentPointProto = sortedTrack[i]
val currentPoint = currentPointProto.toLatLng()
val lastAddedPoint = lastAddedPointProto.toLatLng()
val distanceResults = FloatArray(1)
Location.distanceBetween(
lastAddedPoint.latitude,
lastAddedPoint.longitude,
currentPoint.latitude,
currentPoint.longitude,
distanceResults,
)
if (distanceResults[0] > MIN_TRACK_POINT_DISTANCE_METERS) {
filteredPoints.add(currentPointProto)
lastAddedPointProto = currentPointProto
}
}
val lastOriginalPointProto = sortedTrack.last()
if (filteredPoints.last() != lastOriginalPointProto) {
val distanceResults = FloatArray(1)
val lastAddedPoint = lastAddedPointProto.toLatLng()
val lastOriginalPoint = lastOriginalPointProto.toLatLng()
Location.distanceBetween(
lastAddedPoint.latitude,
lastAddedPoint.longitude,
lastOriginalPoint.latitude,
lastOriginalPoint.longitude,
distanceResults,
)
if (distanceResults[0] > MIN_TRACK_POINT_DISTANCE_METERS || filteredPoints.size == 1) {
filteredPoints.add(lastAddedPointProto)
}
}
return filteredPoints
}
@Suppress("CyclomaticComplexMethod", "LongMethod")
@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MapView(
uiViewModel: UIViewModel,
mapViewModel: MapViewModel = hiltViewModel(),
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int? = null,
nodeTrack: List<Position>? = null,
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
var hasLocationPermission by remember { mutableStateOf(false) }
val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
LocationPermissionsHandler { isGranted -> hasLocationPermission = isGranted }
val kmlFilePickerLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == android.app.Activity.RESULT_OK) {
result.data?.data?.let { uri ->
val fileName = uri.getFileName(context)
mapViewModel.addMapLayer(uri, fileName)
}
}
}
var mapFilterMenuExpanded by remember { mutableStateOf(false) }
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val ourNodeInfo by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle()
var editingWaypoint by remember { mutableStateOf<Waypoint?>(null) }
val savedCameraPosition by mapViewModel.cameraPosition.collectAsStateWithLifecycle()
val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
val currentCustomTileProviderUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
var mapTypeMenuExpanded by remember { mutableStateOf(false) }
var showCustomTileManagerSheet by remember { mutableStateOf(false) }
val defaultLatLng = LatLng(0.0, 0.0)
val cameraPositionState = rememberCameraPositionState {
position =
savedCameraPosition?.let {
CameraPosition(LatLng(it.targetLat, it.targetLng), it.zoom, it.tilt, it.bearing)
} ?: CameraPosition.fromLatLngZoom(defaultLatLng, 7f)
}
val floatingToolbarState = rememberFloatingToolbarState()
val exitAlwaysScrollBehavior =
FloatingToolbarDefaults.exitAlwaysScrollBehavior(exitDirection = End, state = floatingToolbarState)
LaunchedEffect(cameraPositionState.isMoving, floatingToolbarState.offsetLimit) {
val targetOffset =
if (cameraPositionState.isMoving) {
floatingToolbarState.offsetLimit
} else {
mapViewModel.onCameraPositionChanged(cameraPositionState.position)
0f
}
if (floatingToolbarState.offset != targetOffset) {
if (targetOffset == 0f || floatingToolbarState.offsetLimit != 0f) {
launch {
animate(initialValue = floatingToolbarState.offset, targetValue = targetOffset) { value, _ ->
floatingToolbarState.offset = value
}
}
}
}
}
val allNodes by
mapViewModel.nodes
.map { nodes -> nodes.filter { node -> node.validPosition != null } }
.collectAsStateWithLifecycle(listOf())
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint }
var hasZoomed by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(allNodes, displayableWaypoints, nodeTrack) {
if ((hasZoomed) || cameraPositionState.cameraMoveStartedReason != CameraMoveStartedReason.NO_MOVEMENT_YET) {
if (!hasZoomed) hasZoomed = true
return@LaunchedEffect
}
val pointsToBound: List<LatLng> =
when {
!nodeTrack.isNullOrEmpty() -> nodeTrack.map { it.toLatLng() }
allNodes.isNotEmpty() || displayableWaypoints.isNotEmpty() ->
allNodes.mapNotNull { it.toLatLng() } + displayableWaypoints.map { it.toLatLng() }
else -> emptyList()
}
if (pointsToBound.isNotEmpty()) {
val bounds = LatLngBounds.builder().apply { pointsToBound.forEach(::include) }.build()
val padding = if (!pointsToBound.isEmpty()) 100 else 48
try {
cameraPositionState.animate(CameraUpdateFactory.newLatLngBounds(bounds, padding))
hasZoomed = true
} catch (e: IllegalStateException) {
warn("MapView Could not animate to bounds: ${e.message}")
}
}
}
val filteredNodes =
if (mapFilterState.onlyFavorites) {
allNodes.filter { it.isFavorite || it.num == ourNodeInfo?.num }
} else {
allNodes
}
val nodeClusterItems =
filteredNodes.map { node ->
val latLng = LatLng(node.position.latitudeI * DEG_D, node.position.longitudeI * DEG_D)
NodeClusterItem(
node = node,
nodePosition = latLng,
nodeTitle = "${node.user.shortName} ${formatAgo(node.position.time)}",
nodeSnippet = "${node.user.longName}",
)
}
val isConnected by uiViewModel.isConnectedStateFlow.collectAsStateWithLifecycle()
val theme by uiViewModel.theme.collectAsStateWithLifecycle()
val dark =
when (theme) {
AppCompatDelegate.MODE_NIGHT_YES -> true
AppCompatDelegate.MODE_NIGHT_NO -> false
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme()
else -> isSystemInDarkTheme()
}
val mapColorScheme =
when (dark) {
true -> ComposeMapColorScheme.DARK
else -> ComposeMapColorScheme.LIGHT
}
var showLayersBottomSheet by remember { mutableStateOf(false) }
val onAddLayerClicked = {
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
val mimeTypes = arrayOf("application/vnd.google-earth.kml+xml", "application/vnd.google-earth.kmz")
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
}
kmlFilePickerLauncher.launch(intent)
}
val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
val effectiveGoogleMapType =
if (currentCustomTileProviderUrl != null) {
MapType.NONE
} else {
selectedGoogleMapType
}
var showClusterItemsDialog by remember { mutableStateOf<List<NodeClusterItem>?>(null) }
Scaffold(modifier = Modifier.nestedScroll(exitAlwaysScrollBehavior)) { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
GoogleMap(
mapColorScheme = mapColorScheme,
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
uiSettings =
MapUiSettings(
zoomControlsEnabled = true,
mapToolbarEnabled = true,
compassEnabled = true,
myLocationButtonEnabled = hasLocationPermission,
rotationGesturesEnabled = true,
scrollGesturesEnabled = true,
tiltGesturesEnabled = true,
zoomGesturesEnabled = true,
),
properties =
MapProperties(mapType = effectiveGoogleMapType, isMyLocationEnabled = hasLocationPermission),
onMapLongClick = { latLng ->
if (isConnected) {
val newWaypoint = waypoint {
latitudeI = (latLng.latitude / DEG_D).toInt()
longitudeI = (latLng.longitude / DEG_D).toInt()
}
editingWaypoint = newWaypoint
}
},
) {
key(currentCustomTileProviderUrl) {
currentCustomTileProviderUrl?.let { url ->
mapViewModel.createUrlTileProvider(url)?.let { tileProvider ->
TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
}
}
}
if (nodeTrack != null && focusedNodeNum != null) {
val originalLatLngs =
nodeTrack.sortedBy { it.time }.map { LatLng(it.latitudeI * DEG_D, it.longitudeI * DEG_D) }
val filteredLatLngs = filterNodeTrack(nodeTrack)
val focusedNode = allNodes.find { it.num == focusedNodeNum }
val polylineColor = focusedNode?.colors?.let { Color(it.first) } ?: Color.Blue
if (originalLatLngs.isNotEmpty()) {
focusedNode?.let {
MarkerComposable(
state = rememberUpdatedMarkerState(position = originalLatLngs.first()),
zIndex = 1f,
) {
NodeChip(node = it, isThisNode = false, isConnected = false, onAction = {})
}
}
}
val pointsForMarkers =
if (originalLatLngs.isNotEmpty() && focusedNode != null) {
filteredLatLngs.drop(1)
} else {
filteredLatLngs
}
pointsForMarkers.forEachIndexed { index, position ->
val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
val dateFormat = remember {
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
}
val alpha = 1 - (index.toFloat() / pointsForMarkers.size.toFloat())
MarkerInfoWindowComposable(
state = markerState,
title = stringResource(R.string.position),
snippet = formatAgo(position.time),
zIndex = alpha,
infoContent = {
PositionInfoWindowContent(
position = position,
dateFormat = dateFormat,
displayUnits = displayUnits,
)
},
) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Default.TripOrigin,
contentDescription = stringResource(R.string.track_point),
modifier = Modifier.padding(8.dp),
tint = polylineColor.copy(alpha = alpha),
)
}
}
if (filteredLatLngs.size > 1) {
Polyline(
points = filteredLatLngs.map { it.toLatLng() },
jointType = JointType.ROUND,
endCap = RoundCap(),
startCap = RoundCap(),
geodesic = true,
color = polylineColor,
width = 8f,
zIndex = 0f,
)
}
} else {
NodeClusterMarkers(
nodeClusterItems = nodeClusterItems,
mapFilterState = mapFilterState,
navigateToNodeDetails = navigateToNodeDetails,
onClusterClick = { cluster ->
val items = cluster.items.toList()
val allSameLocation = items.size > 1 && items.all { it.position == items.first().position }
if (allSameLocation) {
showClusterItemsDialog = items
} else {
val bounds = LatLngBounds.builder()
cluster.items.forEach { bounds.include(it.position) }
coroutineScope.launch {
cameraPositionState.animate(
CameraUpdateFactory.newLatLngBounds(bounds.build(), 100),
)
}
debug("Cluster clicked! $cluster")
}
true
},
)
}
WaypointMarkers(
displayableWaypoints = displayableWaypoints,
mapFilterState = mapFilterState,
myNodeNum = uiViewModel.myNodeNum ?: 0,
isConnected = isConnected,
unicodeEmojiToBitmapProvider = ::unicodeEmojiToBitmap,
onEditWaypointRequest = { waypointToEdit -> editingWaypoint = waypointToEdit },
)
MapEffect(mapLayers) { map ->
mapLayers.forEach { layerItem ->
mapViewModel.loadKmlLayerIfNeeded(map, layerItem)?.let { kmlLayer ->
if (layerItem.isVisible && !kmlLayer.isLayerOnMap) {
kmlLayer.addLayerToMap()
} else if (!layerItem.isVisible && kmlLayer.isLayerOnMap) {
kmlLayer.removeLayerFromMap()
}
}
}
}
}
DisappearingScaleBar(cameraPositionState = cameraPositionState)
editingWaypoint?.let { waypointToEdit ->
EditWaypointDialog(
waypoint = waypointToEdit,
onSendClicked = { updatedWp ->
var finalWp = updatedWp
if (updatedWp.id == 0) {
finalWp = finalWp.copy { id = uiViewModel.generatePacketId() ?: 0 }
}
if (updatedWp.icon == 0) {
finalWp = finalWp.copy { icon = 0x1F4CD }
}
uiViewModel.sendWaypoint(finalWp)
editingWaypoint = null
},
onDeleteClicked = { wpToDelete ->
if (wpToDelete.lockedTo == 0 && isConnected && wpToDelete.id != 0) {
val deleteMarkerWp = wpToDelete.copy { expire = 1 }
uiViewModel.sendWaypoint(deleteMarkerWp)
}
uiViewModel.deleteWaypoint(wpToDelete.id)
editingWaypoint = null
},
onDismissRequest = { editingWaypoint = null },
)
}
MapControlsOverlay(
modifier = Modifier.align(Alignment.CenterEnd).offset(x = -ScreenOffset),
mapFilterMenuExpanded = mapFilterMenuExpanded,
onMapFilterMenuDismissRequest = { mapFilterMenuExpanded = false },
onToggleMapFilterMenu = { mapFilterMenuExpanded = true },
mapViewModel = mapViewModel,
mapTypeMenuExpanded = mapTypeMenuExpanded,
onMapTypeMenuDismissRequest = { mapTypeMenuExpanded = false },
onToggleMapTypeMenu = { mapTypeMenuExpanded = true },
onManageLayersClicked = { showLayersBottomSheet = true },
onManageCustomTileProvidersClicked = {
mapTypeMenuExpanded = false
showCustomTileManagerSheet = true
},
showFilterButton = focusedNodeNum == null,
scrollBehavior = exitAlwaysScrollBehavior,
)
}
if (showLayersBottomSheet) {
ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) {
CustomMapLayersSheet(mapLayers, onToggleVisibility, onRemoveLayer, onAddLayerClicked)
}
}
showClusterItemsDialog?.let {
ClusterItemsListDialog(
items = it,
onDismiss = { showClusterItemsDialog = null },
onItemClick = { item ->
navigateToNodeDetails(item.node.num)
showClusterItemsDialog = null
},
)
}
if (showCustomTileManagerSheet) {
ModalBottomSheet(onDismissRequest = { showCustomTileManagerSheet = false }) {
CustomTileProviderManagerSheet(mapViewModel = mapViewModel)
}
}
}
}
internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
String(Character.toChars(unicodeCodePoint))
} catch (e: IllegalArgumentException) {
Timber.w(e, "Invalid unicode code point: $unicodeCodePoint")
"\uD83D\uDCCD"
}
internal fun unicodeEmojiToBitmap(icon: Int): BitmapDescriptor {
val unicodeEmoji = convertIntToEmoji(icon)
val paint =
Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 64f
color = android.graphics.Color.BLACK
textAlign = Paint.Align.CENTER
}
val baseline = -paint.ascent()
val width = (paint.measureText(unicodeEmoji) + 0.5f).toInt()
val height = (baseline + paint.descent() + 0.5f).toInt()
val image = createBitmap(width, height, android.graphics.Bitmap.Config.ARGB_8888)
val canvas = Canvas(image)
canvas.drawText(unicodeEmoji, width / 2f, baseline, paint)
return BitmapDescriptorFactory.fromBitmap(image)
}
@Suppress("NestedBlockDepth")
fun Uri.getFileName(context: android.content.Context): String {
var name = this.lastPathSegment ?: "layer_${System.currentTimeMillis()}"
if (this.scheme == "content") {
context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (displayNameIndex != -1) {
name = cursor.getString(displayNameIndex)
}
}
}
}
return name
}
data class NodeClusterItem(val node: Node, val nodePosition: LatLng, val nodeTitle: String, val nodeSnippet: String) :
ClusterItem {
override fun getPosition(): LatLng = nodePosition
override fun getTitle(): String = nodeTitle
override fun getSnippet(): String = nodeSnippet
override fun getZIndex(): Float? = null
fun getPrecisionMeters(): Double? {
val precisionMap =
mapOf(
10 to 23345.484932,
11 to 11672.7369,
12 to 5836.36288,
13 to 2918.175876,
14 to 1459.0823719999053,
15 to 729.53562,
16 to 364.7622,
17 to 182.375556,
18 to 91.182212,
19 to 45.58554,
)
return precisionMap[this.node.position.precisionBits]
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@Suppress("LongMethod")
private fun PositionInfoWindowContent(
position: Position,
dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM),
displayUnits: DisplayUnits = DisplayUnits.METRIC,
) {
@Composable
fun PositionRow(label: String, value: String) {
Row(modifier = Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(label, style = MaterialTheme.typography.labelMedium)
Spacer(modifier = Modifier.width(16.dp))
Text(value, style = MaterialTheme.typography.labelMediumEmphasized)
}
}
Card {
Column(modifier = Modifier.padding(8.dp)) {
PositionRow(
label = stringResource(R.string.latitude),
value = "%.5f".format(position.latitudeI * com.geeksville.mesh.ui.metrics.DEG_D),
)
PositionRow(
label = stringResource(R.string.longitude),
value = "%.5f".format(position.longitudeI * com.geeksville.mesh.ui.metrics.DEG_D),
)
PositionRow(label = stringResource(R.string.sats), value = position.satsInView.toString())
PositionRow(
label = stringResource(R.string.alt),
value = position.altitude.metersIn(displayUnits).toString(displayUnits),
)
PositionRow(label = stringResource(R.string.speed), value = speedFromPosition(position, displayUnits))
PositionRow(
label = stringResource(R.string.heading),
value = "%.0f°".format(position.groundTrack * HEADING_DEG),
)
PositionRow(label = stringResource(R.string.timestamp), value = formatPositionTime(position, dateFormat))
}
}
}
@Composable
private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String {
val speedInMps = position.groundSpeed
val mpsText = "%d m/s".format(speedInMps)
val speedText =
if (speedInMps > 10) {
when (displayUnits) {
DisplayUnits.METRIC -> "%.1f Km/h".format(position.groundSpeed.mpsToKmph())
DisplayUnits.IMPERIAL -> "%.1f mph".format(position.groundSpeed.mpsToMph())
else -> mpsText // Fallback or handle UNRECOGNIZED
}
} else {
mpsText
}
return speedText
}
private fun Position.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D)
private fun Node.toLatLng(): LatLng? = this.position.toLatLng()
private fun Waypoint.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D)

View File

@@ -0,0 +1,383 @@
/*
* 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.content.SharedPreferences
import android.net.Uri
import android.util.Log
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.database.NodeRepository
import com.geeksville.mesh.database.PacketRepository
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.repository.map.CustomTileProviderRepository
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.CameraPosition
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.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.map
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 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")
@HiltViewModel
class MapViewModel
@Inject
constructor(
private val application: Application,
preferences: SharedPreferences,
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioConfigRepository: RadioConfigRepository,
private val customTileProviderRepository: CustomTileProviderRepository,
) : BaseMapViewModel(preferences, nodeRepository, packetRepository) {
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>(MapType.NORMAL)
val selectedGoogleMapType: StateFlow<MapType> = _selectedGoogleMapType.asStateFlow()
private val _cameraPosition = MutableStateFlow<MapCameraPosition?>(null)
val cameraPosition: StateFlow<MapCameraPosition?> = _cameraPosition.asStateFlow()
val displayUnits =
radioConfigRepository.deviceProfileFlow
.mapNotNull { it.config.display.units }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC,
)
fun onCameraPositionChanged(cameraPosition: CameraPosition) {
_cameraPosition.value =
MapCameraPosition(
targetLat = cameraPosition.target.latitude,
targetLng = cameraPosition.target.longitude,
zoom = cameraPosition.zoom,
tilt = cameraPosition.tilt,
bearing = cameraPosition.bearing,
)
}
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
}
}
}
fun selectCustomTileProvider(config: CustomTileProviderConfig?) {
if (config != null) {
if (!isValidTileUrlTemplate(config.urlTemplate)) {
Log.w("MapViewModel", "Attempted to select invalid URL template: ${config.urlTemplate}")
_selectedCustomTileProviderUrl.value = null
return
}
_selectedCustomTileProviderUrl.value = config.urlTemplate
} else {
_selectedCustomTileProviderUrl.value = null
}
}
fun setSelectedGoogleMapType(mapType: MapType) {
_selectedGoogleMapType.value = mapType
if (_selectedCustomTileProviderUrl.value != null) {
_selectedCustomTileProviderUrl.value = null
}
}
fun createUrlTileProvider(urlString: String): TileProvider? {
if (!isValidTileUrlTemplate(urlString)) {
Log.e("MapViewModel", "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) {
Log.e("MapViewModel", "Malformed URL: $formattedUrl", e)
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 {
loadPersistedLayers()
}
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 loadedItems =
persistedLayerFiles.mapNotNull { file ->
if (file.isFile) {
MapLayerItem(
name = file.nameWithoutExtension,
uri = Uri.fromFile(file),
isVisible = true,
)
} else {
null
}
}
_mapLayers.value = loadedItems
if (loadedItems.isNotEmpty()) {
Log.i("MapViewModel", "Loaded ${loadedItems.size} persisted map layers.")
}
}
} else {
Log.i("MapViewModel", "Map layers directory does not exist. No layers loaded.")
}
} catch (e: Exception) {
Log.e("MapViewModel", "Error loading persisted map layers", e)
_mapLayers.value = emptyList()
}
}
}
fun addMapLayer(uri: Uri, fileName: String?) {
viewModelScope.launch {
val layerName = fileName ?: "Layer ${mapLayers.value.size + 1}"
val localFileUri = copyFileToInternalStorage(uri, fileName ?: "layer_${UUID.randomUUID()}")
if (localFileUri != null) {
val newItem = MapLayerItem(name = layerName, uri = localFileUri)
_mapLayers.value = _mapLayers.value + newItem
} else {
Log.e("MapViewModel", "Failed to copy KML/KMZ 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) {
Log.e("MapViewModel", "Error copying file to internal storage", e)
null
}
}
fun toggleLayerVisibility(layerId: String) {
_mapLayers.value = _mapLayers.value.map { if (it.id == layerId) it.copy(isVisible = !it.isVisible) else it }
}
fun removeMapLayer(layerId: String) {
viewModelScope.launch {
val layerToRemove = _mapLayers.value.find { it.id == layerId }
layerToRemove?.kmlLayerData?.removeLayerFromMap()
layerToRemove?.uri?.let { uri -> deleteFileFromInternalStorage(uri) }
_mapLayers.value = _mapLayers.value.filterNot { it.id == layerId }
}
}
private suspend fun deleteFileFromInternalStorage(uri: Uri) {
withContext(Dispatchers.IO) {
try {
val file = File(uri.path ?: return@withContext)
if (file.exists()) {
file.delete()
}
} catch (e: Exception) {
Log.e("MapViewModel", "Error deleting file from internal storage", e)
}
}
}
@Suppress("Recycle")
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
val uriToLoad = layerItem.uri ?: return null
val stream =
withContext(Dispatchers.IO) {
try {
application.contentResolver.openInputStream(uriToLoad)
} catch (_: Exception) {
debug("MapViewModel: Error opening InputStream from URI: $uriToLoad")
null
}
}
return stream
}
suspend fun loadKmlLayerIfNeeded(map: GoogleMap, layerItem: MapLayerItem): KmlLayer? {
if (layerItem.kmlLayerData != null) {
return layerItem.kmlLayerData
}
return try {
getInputStreamFromUri(layerItem)?.use { inputStream ->
val kmlLayer = KmlLayer(map, inputStream, application.applicationContext)
_mapLayers.update { currentLayers ->
currentLayers.map { if (it.id == layerItem.id) it.copy(kmlLayerData = kmlLayer) else it }
}
kmlLayer
}
} catch (e: Exception) {
Log.e("MapViewModel", "Error loading KML for ${layerItem.uri}", e)
null
}
}
}
data class MapLayerItem(
val id: String = UUID.randomUUID().toString(),
val name: String,
val uri: Uri? = null,
var isVisible: Boolean = true,
var kmlLayerData: KmlLayer? = null,
)
@Serializable
data class CustomTileProviderConfig(
val id: String = UUID.randomUUID().toString(),
val name: String,
val urlTemplate: String,
)

View File

@@ -0,0 +1,75 @@
/*
* 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.clickable
import androidx.compose.foundation.layout.PaddingValues
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.material3.AlertDialog
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.R
import com.geeksville.mesh.ui.map.NodeClusterItem
import com.geeksville.mesh.ui.node.components.NodeChip
@Composable
fun ClusterItemsListDialog(
items: List<NodeClusterItem>,
onDismiss: () -> Unit,
onItemClick: (NodeClusterItem) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.nodes_at_this_location)) },
text = {
// Use a LazyColumn for potentially long lists of items
LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(items, key = { it.node.num }) { item ->
ClusterDialogListItem(item = item, onClick = { onItemClick(item) })
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.okay)) } },
)
}
@Composable
private fun ClusterDialogListItem(item: NodeClusterItem, onClick: () -> Unit, modifier: Modifier = Modifier) {
ListItem(
leadingContent = { NodeChip(node = item.node, enabled = false, isThisNode = false, isConnected = false) {} },
headlineContent = { Text(item.nodeTitle) },
supportingContent = {
if (item.nodeSnippet.isNotBlank()) {
Text(item.nodeSnippet)
}
},
modifier =
modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 4.dp), // Add some padding around list items
)
}

View File

@@ -0,0 +1,114 @@
/*
* 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.R
import com.geeksville.mesh.ui.map.MapLayerItem
@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()
}
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

@@ -0,0 +1,258 @@
/*
* 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.R
import com.geeksville.mesh.ui.map.CustomTileProviderConfig
import com.geeksville.mesh.ui.map.MapViewModel
import kotlinx.coroutines.flow.collectLatest
@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

@@ -0,0 +1,338 @@
/*
* 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.app.DatePickerDialog
import android.app.TimePickerDialog
import android.widget.DatePicker
import android.widget.TimePicker
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.geeksville.mesh.MeshProtos.Waypoint
import com.geeksville.mesh.R
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.common.components.EmojiPickerDialog
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
@Composable
fun EditWaypointDialog(
waypoint: Waypoint,
onSendClicked: (Waypoint) -> Unit,
onDeleteClicked: (Waypoint) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
var waypointInput by remember { mutableStateOf(waypoint) }
val title = if (waypoint.id == 0) R.string.waypoint_new else R.string.waypoint_edit
val defaultEmoji = 0x1F4CD // 📍 Round Pushpin
val currentEmojiCodepoint = if (waypointInput.icon == 0) defaultEmoji else waypointInput.icon
var showEmojiPickerView by remember { mutableStateOf(false) }
val context = LocalContext.current
val calendar = remember { Calendar.getInstance() }
// Initialize date and time states from waypointInput.expire
var selectedDateString by remember { mutableStateOf("") }
var selectedTimeString by remember { mutableStateOf("") }
var isExpiryEnabled by remember {
mutableStateOf(waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE)
}
val locale = Locale.getDefault()
val dateFormat = remember {
if (locale.country.equals("US", ignoreCase = true)) {
SimpleDateFormat("MM/dd/yyyy", locale)
} else {
SimpleDateFormat("dd/MM/yyyy", locale)
}
}
val timeFormat = remember {
val is24Hour = android.text.format.DateFormat.is24HourFormat(context)
if (is24Hour) {
SimpleDateFormat("HH:mm", locale)
} else {
SimpleDateFormat("hh:mm a", locale)
}
}
dateFormat.timeZone = TimeZone.getDefault()
timeFormat.timeZone = TimeZone.getDefault()
LaunchedEffect(waypointInput.expire, isExpiryEnabled) {
if (isExpiryEnabled) {
if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
calendar.timeInMillis = waypointInput.expire * 1000L
selectedDateString = dateFormat.format(calendar.time)
selectedTimeString = timeFormat.format(calendar.time)
} else { // If enabled but not set, default to 8 hours from now
calendar.timeInMillis = System.currentTimeMillis()
calendar.add(Calendar.HOUR_OF_DAY, 8)
waypointInput = waypointInput.copy { expire = (calendar.timeInMillis / 1000).toInt() }
}
} else {
selectedDateString = ""
selectedTimeString = ""
}
}
if (!showEmojiPickerView) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(
text = stringResource(title),
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
},
text = {
Column(modifier = modifier.fillMaxWidth()) {
OutlinedTextField(
value = waypointInput.name,
onValueChange = { waypointInput = waypointInput.copy { name = it.take(29) } },
label = { Text(stringResource(R.string.name)) },
singleLine = true,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
IconButton(onClick = { showEmojiPickerView = true }) {
Text(
text = String(Character.toChars(currentEmojiCodepoint)),
modifier =
Modifier.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape)
.padding(6.dp),
fontSize = 20.sp,
)
}
},
)
Spacer(modifier = Modifier.size(8.dp))
OutlinedTextField(
value = waypointInput.description,
onValueChange = { waypointInput = waypointInput.copy { description = it.take(99) } },
label = { Text(stringResource(R.string.description)) },
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { /* Handle next/done focus */ }),
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 3,
)
Spacer(modifier = Modifier.size(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
imageVector = Icons.Default.Lock,
contentDescription = stringResource(R.string.locked),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.locked))
}
Switch(
checked = waypointInput.lockedTo != 0,
onCheckedChange = { waypointInput = waypointInput.copy { lockedTo = if (it) 1 else 0 } },
)
}
Spacer(modifier = Modifier.size(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
imageVector = Icons.Default.CalendarMonth,
contentDescription = stringResource(R.string.expires),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.expires))
}
Switch(
checked = isExpiryEnabled,
onCheckedChange = { checked ->
isExpiryEnabled = checked
if (checked) {
// Default to 8 hours from now if not already set
if (waypointInput.expire == 0 || waypointInput.expire == Int.MAX_VALUE) {
val cal = Calendar.getInstance()
cal.timeInMillis = System.currentTimeMillis()
cal.add(Calendar.HOUR_OF_DAY, 8)
waypointInput =
waypointInput.copy { expire = (cal.timeInMillis / 1000).toInt() }
}
// LaunchedEffect will update date/time strings
} else {
waypointInput = waypointInput.copy { expire = Int.MAX_VALUE }
}
},
)
}
if (isExpiryEnabled) {
val currentCalendar =
Calendar.getInstance().apply {
if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
timeInMillis = waypointInput.expire * 1000L
} else {
timeInMillis = System.currentTimeMillis()
add(Calendar.HOUR_OF_DAY, 8) // Default if re-enabling
}
}
val year = currentCalendar.get(Calendar.YEAR)
val month = currentCalendar.get(Calendar.MONTH)
val day = currentCalendar.get(Calendar.DAY_OF_MONTH)
val hour = currentCalendar.get(Calendar.HOUR_OF_DAY)
val minute = currentCalendar.get(Calendar.MINUTE)
val datePickerDialog =
DatePickerDialog(
context,
{ _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
calendar.clear()
calendar.set(selectedYear, selectedMonth, selectedDay, hour, minute)
waypointInput =
waypointInput.copy { expire = (calendar.timeInMillis / 1000).toInt() }
},
year,
month,
day,
)
val timePickerDialog =
TimePickerDialog(
context,
{ _: TimePicker, selectedHour: Int, selectedMinute: Int ->
// Keep the existing date part
val tempCal = Calendar.getInstance()
tempCal.timeInMillis = waypointInput.expire * 1000L
tempCal.set(Calendar.HOUR_OF_DAY, selectedHour)
tempCal.set(Calendar.MINUTE, selectedMinute)
waypointInput =
waypointInput.copy { expire = (tempCal.timeInMillis / 1000).toInt() }
},
hour,
minute,
android.text.format.DateFormat.is24HourFormat(context),
)
Spacer(modifier = Modifier.size(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = { datePickerDialog.show() }) { Text(stringResource(R.string.date)) }
Text(
modifier = Modifier.padding(top = 4.dp),
text = selectedDateString,
style = MaterialTheme.typography.bodyMedium,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = { timePickerDialog.show() }) { Text(stringResource(R.string.time)) }
Text(
modifier = Modifier.padding(top = 4.dp),
text = selectedTimeString,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
},
confirmButton = {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.End,
) {
if (waypoint.id != 0) {
TextButton(
onClick = { onDeleteClicked(waypointInput) },
modifier = Modifier.padding(end = 8.dp),
) {
Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error)
}
}
Spacer(modifier = Modifier.weight(1f)) // Pushes delete to left and cancel/send to right
TextButton(onClick = onDismissRequest, modifier = Modifier.padding(end = 8.dp)) {
Text(stringResource(R.string.cancel))
}
Button(onClick = { onSendClicked(waypointInput) }, enabled = waypointInput.name.isNotBlank()) {
Text(stringResource(R.string.send))
}
}
},
dismissButton = null, // Using custom buttons in confirmButton Row
modifier = modifier,
)
} else {
EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) { selectedEmoji ->
showEmojiPickerView = false
waypointInput = waypointInput.copy { icon = selectedEmoji.codePointAt(0) }
}
}
}

View File

@@ -0,0 +1,31 @@
/*
* 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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun MapButton(icon: ImageVector, contentDescription: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
FilledIconButton(onClick = onClick, modifier = modifier) {
Icon(imageVector = icon, contentDescription = contentDescription)
}
}

View File

@@ -0,0 +1,93 @@
/*
* 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.outlined.Layers
import androidx.compose.material.icons.outlined.Map
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingToolbarScrollBehavior
import androidx.compose.material3.VerticalFloatingToolbar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.geeksville.mesh.R
import com.geeksville.mesh.ui.map.MapViewModel
@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
showFilterButton: Boolean,
scrollBehavior: FloatingToolbarScrollBehavior,
) {
VerticalFloatingToolbar(
modifier = modifier,
expanded = true,
leadingContent = {},
trailingContent = {},
scrollBehavior = scrollBehavior,
content = {
if (showFilterButton) {
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,
)
},
)
}

View File

@@ -0,0 +1,89 @@
/*
* 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.Favorite
import androidx.compose.material.icons.filled.Place
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.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.R
import com.geeksville.mesh.ui.map.MapViewModel
@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.Favorite,
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() },
)
},
)
}
}

View File

@@ -0,0 +1,104 @@
/*
* 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.R
import com.geeksville.mesh.ui.map.MapViewModel
import com.google.maps.android.compose.MapType
@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

@@ -0,0 +1,70 @@
/*
* 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.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
import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.clustering.Clustering
@OptIn(MapsComposeExperimentalApi::class)
@Suppress("NestedBlockDepth")
@Composable
fun NodeClusterMarkers(
nodeClusterItems: List<NodeClusterItem>,
mapFilterState: BaseMapViewModel.MapFilterState,
navigateToNodeDetails: (Int) -> Unit,
onClusterClick: (Cluster<NodeClusterItem>) -> Boolean,
) {
if (mapFilterState.showPrecisionCircle) {
nodeClusterItems.forEach { clusterItem ->
key(clusterItem.node.num) {
// Add a stable key for each circle
clusterItem.getPrecisionMeters()?.let { precisionMeters ->
if (precisionMeters > 0) {
Circle(
center = clusterItem.position,
radius = precisionMeters,
fillColor = Color(clusterItem.node.colors.second).copy(alpha = 0.2f),
strokeColor = Color(clusterItem.node.colors.second),
strokeWidth = 2f,
zIndex = 1f, // Ensure circles are drawn above markers
)
}
}
}
}
}
Clustering(
items = nodeClusterItems,
onClusterClick = onClusterClick,
onClusterItemInfoWindowClick = { item ->
navigateToNodeDetails(item.node.num)
false
},
clusterItemContent = { clusterItem ->
NodeChip(node = clusterItem.node, enabled = false, isThisNode = false, isConnected = false) {}
},
)
}

View File

@@ -0,0 +1,70 @@
/*
* 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.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.R
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
@Composable
fun WaypointMarkers(
displayableWaypoints: List<MeshProtos.Waypoint>,
mapFilterState: BaseMapViewModel.MapFilterState,
myNodeNum: Int,
isConnected: Boolean,
unicodeEmojiToBitmapProvider: (Int) -> BitmapDescriptor,
onEditWaypointRequest: (MeshProtos.Waypoint) -> Unit,
) {
val context = LocalContext.current
if (mapFilterState.showWaypoints) {
displayableWaypoints.forEach { waypoint ->
val markerState =
rememberUpdatedMarkerState(position = LatLng(waypoint.latitudeI * DEG_D, waypoint.longitudeI * DEG_D))
Marker(
state = markerState,
icon =
if (waypoint.icon == 0) {
unicodeEmojiToBitmapProvider(PUSHPIN) // Default icon (Round Pushpin)
} else {
unicodeEmojiToBitmapProvider(waypoint.icon)
},
title = waypoint.name,
snippet = waypoint.description,
visible = true,
onInfoWindowClick = {
if (waypoint.lockedTo == 0 || waypoint.lockedTo == myNodeNum || !isConnected) {
onEditWaypointRequest(waypoint)
} else {
Toast.makeText(context, context.getString(R.string.locked), Toast.LENGTH_SHORT).show()
}
},
)
}
}
}
private const val PUSHPIN = 0x1F4CD // Unicode for Round Pushpin

View File

@@ -0,0 +1,36 @@
/*
* 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.node
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.hilt.navigation.compose.hiltViewModel
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.map.MapView
const val DEG_D = 1e-7
@Composable
fun NodeMapScreen(uiViewModel: UIViewModel, metricsViewModel: MetricsViewModel = hiltViewModel()) {
val state by metricsViewModel.state.collectAsState()
val positions = state.positionLogs
val destNum = state.node?.num
MapView(uiViewModel = uiViewModel, focusedNodeNum = destNum, nodeTrack = positions, navigateToNodeDetails = {})
}