More map modularization (#3319)

This commit is contained in:
Phil Oliver 2025-10-03 20:19:37 -04:00 committed by GitHub
parent bc114c618a
commit 51fa634e11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 145 additions and 146 deletions

View file

@ -1,751 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package com.geeksville.mesh.ui.map
import android.app.Activity
import android.content.Intent
import android.graphics.Canvas
import android.graphics.Paint
import android.net.Uri
import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatDelegate
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.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.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
import com.geeksville.mesh.MeshProtos.Position
import com.geeksville.mesh.MeshProtos.Waypoint
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.map.components.ClusterItemsListDialog
import com.geeksville.mesh.ui.map.components.EditWaypointDialog
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.waypoint
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import 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.maps.android.clustering.ClusterItem
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.ScaleBar
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.formatAgo
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.mpsToKmph
import org.meshtastic.core.model.util.mpsToMph
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.LayerType
import org.meshtastic.feature.map.LocationPermissionsHandler
import org.meshtastic.feature.map.MapViewModel
import org.meshtastic.feature.map.component.CustomMapLayersSheet
import org.meshtastic.feature.map.component.CustomTileProviderManagerSheet
import org.meshtastic.feature.map.component.MapControlsOverlay
import timber.log.Timber
import java.text.DateFormat
private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f
@Suppress("CyclomaticComplexMethod", "LongMethod")
@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MapView(
mapViewModel: MapViewModel = hiltViewModel(),
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int? = null,
nodeTracks: 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()
// Location tracking state
var isLocationTrackingEnabled by remember { mutableStateOf(false) }
var followPhoneBearing by remember { mutableStateOf(false) }
LocationPermissionsHandler { isGranted -> hasLocationPermission = isGranted }
val filePickerLauncher =
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 mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
var editingWaypoint by remember { mutableStateOf<Waypoint?>(null) }
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 cameraPositionState = rememberCameraPositionState {
position =
CameraPosition.fromLatLngZoom(
LatLng(
ourNodeInfo?.position?.latitudeI?.times(DEG_D) ?: 0.0,
ourNodeInfo?.position?.longitudeI?.times(DEG_D) ?: 0.0,
),
7f,
)
}
// Location tracking functionality
val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
val locationCallback = remember {
object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
if (isLocationTrackingEnabled) {
locationResult.lastLocation?.let { location ->
val latLng = LatLng(location.latitude, location.longitude)
val cameraUpdate =
if (followPhoneBearing) {
val bearing =
if (location.hasBearing()) {
location.bearing
} else {
cameraPositionState.position.bearing
}
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder()
.target(latLng)
.zoom(cameraPositionState.position.zoom)
.bearing(bearing)
.build(),
)
} else {
CameraUpdateFactory.newLatLngZoom(latLng, cameraPositionState.position.zoom)
}
coroutineScope.launch {
try {
cameraPositionState.animate(cameraUpdate)
} catch (e: IllegalStateException) {
Timber.d("Error animating camera to location: ${e.message}")
}
}
}
}
}
}
}
// Start/stop location tracking based on state
LaunchedEffect(isLocationTrackingEnabled, hasLocationPermission) {
if (isLocationTrackingEnabled && hasLocationPermission) {
val locationRequest =
LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L)
.setMinUpdateIntervalMillis(2000L)
.build()
try {
fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null)
Timber.d("Started location tracking")
} catch (e: SecurityException) {
Timber.d("Location permission not available: ${e.message}")
isLocationTrackingEnabled = false
}
} else {
fusedLocationClient.removeLocationUpdates(locationCallback)
Timber.d("Stopped location tracking")
}
}
DisposableEffect(Unit) {
onDispose {
fusedLocationClient.removeLocationUpdates(locationCallback)
mapViewModel.clearLoadedLayerData()
}
}
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 }
val filteredNodes =
allNodes
.filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num }
.filter { node ->
mapFilterState.lastHeardFilter.seconds == 0L ||
(System.currentTimeMillis() / 1000 - node.lastHeard) <= mapFilterState.lastHeardFilter.seconds ||
node.num == ourNodeInfo?.num
}
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 mapViewModel.isConnected.collectAsStateWithLifecycle()
val theme by mapViewModel.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",
"application/vnd.geo+json",
"application/geo+json",
"application/json",
)
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
}
filePickerLauncher.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) }
LaunchedEffect(isLocationTrackingEnabled) {
val activity = context as? Activity ?: return@LaunchedEffect
val window = activity.window
if (isLocationTrackingEnabled) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
Scaffold { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
GoogleMap(
mapColorScheme = mapColorScheme,
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
uiSettings =
MapUiSettings(
zoomControlsEnabled = true,
mapToolbarEnabled = true,
compassEnabled = false,
myLocationButtonEnabled = false,
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
}
},
onMapLoaded = {
val pointsToBound: List<LatLng> =
when {
!nodeTracks.isNullOrEmpty() -> nodeTracks.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 {
coroutineScope.launch {
cameraPositionState.animate(CameraUpdateFactory.newLatLngBounds(bounds, padding))
}
} catch (e: IllegalStateException) {
Timber.w("MapView Could not animate to bounds: ${e.message}")
}
}
},
) {
key(currentCustomTileProviderUrl) {
currentCustomTileProviderUrl?.let { url ->
mapViewModel.createUrlTileProvider(url)?.let { tileProvider ->
TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
}
}
}
if (nodeTracks != null && focusedNodeNum != null) {
val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
val timeFilteredPositions =
nodeTracks.filter {
lastHeardTrackFilter == LastHeardFilter.Any ||
it.time > System.currentTimeMillis() / 1000 - lastHeardTrackFilter.seconds
}
val sortedPositions = timeFilteredPositions.sortedBy { it.time }
allNodes
.find { it.num == focusedNodeNum }
?.let { focusedNode ->
sortedPositions.forEachIndexed { index, position ->
val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
val dateFormat = remember {
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
}
val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1))
val color = Color(focusedNode!!.colors.second).copy(alpha = alpha)
if (index == sortedPositions.lastIndex) {
MarkerComposable(state = markerState, zIndex = 1f) { NodeChip(node = focusedNode) }
} else {
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),
tint = color,
)
}
}
}
if (sortedPositions.size > 1 && focusedNode != null) {
val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false)
segments.forEachIndexed { index, segmentPoints ->
val alpha = (index.toFloat() / (segments.size.toFloat() - 1))
Polyline(
points = segmentPoints.map { it.toLatLng() },
jointType = JointType.ROUND,
color = Color(focusedNode.colors.second).copy(alpha = alpha),
width = 8f,
)
}
}
}
} 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),
)
}
Timber.d("Cluster clicked! $cluster")
}
true
},
)
}
WaypointMarkers(
displayableWaypoints = displayableWaypoints,
mapFilterState = mapFilterState,
myNodeNum = mapViewModel.myNodeNum ?: 0,
isConnected = isConnected,
unicodeEmojiToBitmapProvider = ::unicodeEmojiToBitmap,
onEditWaypointRequest = { waypointToEdit -> editingWaypoint = waypointToEdit },
)
MapEffect(mapLayers) { map ->
mapLayers.forEach { layerItem ->
coroutineScope.launch {
mapViewModel.loadMapLayerIfNeeded(map, layerItem)
when (layerItem.layerType) {
LayerType.KML -> {
layerItem.kmlLayerData?.let { kmlLayer ->
if (layerItem.isVisible && !kmlLayer.isLayerOnMap) {
kmlLayer.addLayerToMap()
} else if (!layerItem.isVisible && kmlLayer.isLayerOnMap) {
kmlLayer.removeLayerFromMap()
}
}
}
LayerType.GEOJSON -> {
layerItem.geoJsonLayerData?.let { geoJsonLayer ->
if (layerItem.isVisible && !geoJsonLayer.isLayerOnMap) {
geoJsonLayer.addLayerToMap()
} else if (!layerItem.isVisible && geoJsonLayer.isLayerOnMap) {
geoJsonLayer.removeLayerFromMap()
}
}
}
}
}
}
}
}
ScaleBar(
cameraPositionState = cameraPositionState,
modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 48.dp),
)
editingWaypoint?.let { waypointToEdit ->
EditWaypointDialog(
waypoint = waypointToEdit,
onSendClicked = { updatedWp ->
var finalWp = updatedWp
if (updatedWp.id == 0) {
finalWp = finalWp.copy { id = mapViewModel.generatePacketId() ?: 0 }
}
if (updatedWp.icon == 0) {
finalWp = finalWp.copy { icon = 0x1F4CD }
}
mapViewModel.sendWaypoint(finalWp)
editingWaypoint = null
},
onDeleteClicked = { wpToDelete ->
if (wpToDelete.lockedTo == 0 && isConnected && wpToDelete.id != 0) {
val deleteMarkerWp = wpToDelete.copy { expire = 1 }
mapViewModel.sendWaypoint(deleteMarkerWp)
}
mapViewModel.deleteWaypoint(wpToDelete.id)
editingWaypoint = null
},
onDismissRequest = { editingWaypoint = null },
)
}
MapControlsOverlay(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
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
},
isNodeMap = focusedNodeNum != null,
hasLocationPermission = hasLocationPermission,
isLocationTrackingEnabled = isLocationTrackingEnabled,
onToggleLocationTracking = {
if (hasLocationPermission) {
isLocationTrackingEnabled = !isLocationTrackingEnabled
if (!isLocationTrackingEnabled) {
followPhoneBearing = false
}
}
},
bearing = cameraPositionState.position.bearing,
onCompassClick = {
if (isLocationTrackingEnabled) {
followPhoneBearing = !followPhoneBearing
} else {
coroutineScope.launch {
try {
val currentPosition = cameraPositionState.position
val newCameraPosition = CameraPosition.Builder(currentPosition).bearing(0f).build()
cameraPositionState.animate(CameraUpdateFactory.newCameraPosition(newCameraPosition))
Timber.d("Oriented map to north")
} catch (e: IllegalStateException) {
Timber.d("Error orienting map to north: ${e.message}")
}
}
}
},
followPhoneBearing = followPhoneBearing,
)
}
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

@ -1,75 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.map.components
import androidx.compose.foundation.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.ui.map.NodeClusterItem
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.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) },
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

@ -1,338 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.map.components
import android.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.copy
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.emoji.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

@ -1,72 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.map.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.graphics.Color
import com.geeksville.mesh.ui.map.NodeClusterItem
import com.google.maps.android.clustering.Cluster
import com.google.maps.android.clustering.view.DefaultClusterRenderer
import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.clustering.Clustering
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.feature.map.BaseMapViewModel
@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) },
onClusterManager = { clusterManager ->
(clusterManager.renderer as DefaultClusterRenderer).minClusterSize = 10
},
)
}

View file

@ -1,70 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.map.components
import android.widget.Toast
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.ui.node.DEG_D
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberUpdatedMarkerState
import org.meshtastic.core.strings.R
import org.meshtastic.feature.map.BaseMapViewModel
@Composable
fun WaypointMarkers(
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.replace('\n', ' ').replace('\b', ' '),
snippet = waypoint.description.replace('\n', ' ').replace('\b', ' '),
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

@ -27,11 +27,9 @@ import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.map.MapView
import com.geeksville.mesh.ui.map.NodeMapViewModel
import org.meshtastic.core.ui.component.MainAppBar
const val DEG_D = 1e-7
import org.meshtastic.feature.map.MapView
import org.meshtastic.feature.map.node.NodeMapViewModel
@Composable
fun NodeMapScreen(