diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index 57efc61c7..2a099efc3 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -47,9 +47,7 @@ import org.meshtastic.proto.Waypoint /** * Shared base ViewModel for the map feature, providing node data, waypoints, map filter preferences, and traceroute - * overlay state. - * - * Platform-specific map ViewModels (fdroid/google) extend this to add flavor-specific map provider logic. + * overlay state. [MapViewModel] extends this with camera persistence and map style management. */ @Suppress("TooManyFunctions") open class BaseMapViewModel( @@ -66,14 +64,12 @@ open class BaseMapViewModel( val myNodeNum get() = myNodeInfo.value?.myNodeNum - val myId = nodeRepository.myId - val isConnected = radioController.connectionState .map { it is org.meshtastic.core.model.ConnectionState.Connected } .stateInWhileSubscribed(initialValue = false) - val nodes: StateFlow> = + private val nodes: StateFlow> = nodeRepository .getNodes() .map { nodes -> nodes.filterNot { node -> node.isIgnored } } @@ -89,8 +85,8 @@ open class BaseMapViewModel( .getWaypoints() .mapLatest { list -> list - .filter { it.waypoint != null } - .associateBy { packet -> packet.waypoint!!.id } + .mapNotNull { packet -> packet.waypoint?.let { wpt -> wpt.id to packet } } + .toMap() .filterValues { val expire = it.waypoint?.expire ?: 0 expire == 0 || expire.toLong() > nowSeconds @@ -142,9 +138,6 @@ open class BaseMapViewModel( mapPrefs.setLastHeardTrackFilter(filter.seconds) } - open fun getUser(userId: String?) = - nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST) - fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum) fun deleteWaypoint(id: Int) = viewModelScope.launch(ioDispatcher) { packetRepository.deleteWaypoint(id) } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index 80002f385..daedbfe0d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -49,7 +49,7 @@ import org.meshtastic.feature.map.component.MapFilterDropdown import org.meshtastic.feature.map.component.MapStyleSelector import org.meshtastic.feature.map.component.MaplibreMapContent import org.meshtastic.feature.map.model.MapStyle -import org.meshtastic.feature.map.util.COORDINATE_SCALE +import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.maplibre.spatialk.geojson.Position as GeoPosition private const val WAYPOINT_ZOOM = 15.0 @@ -118,13 +118,8 @@ fun MapScreen( val wpId = selectedWaypointId ?: return@LaunchedEffect val packet = waypoints[wpId] ?: return@LaunchedEffect val wpt = packet.waypoint ?: return@LaunchedEffect - val lat = (wpt.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (wpt.longitude_i ?: 0) * COORDINATE_SCALE - if (lat != 0.0 || lng != 0.0) { - cameraState.animateTo( - CameraPosition(target = GeoPosition(longitude = lng, latitude = lat), zoom = WAYPOINT_ZOOM), - ) - } + val geoPos = toGeoPositionOrNull(wpt.latitude_i, wpt.longitude_i) ?: return@LaunchedEffect + cameraState.animateTo(CameraPosition(target = geoPos, zoom = WAYPOINT_ZOOM)) } @Suppress("ViewModelForwarding") diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt index ccea107ad..5a2b408e9 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt @@ -55,8 +55,8 @@ import org.maplibre.spatialk.geojson.Position as GeoPosition private const val MAX_NAME_LENGTH = 29 private const val MAX_DESCRIPTION_LENGTH = 99 -private const val DEFAULT_EMOJI = 0x1F4CD // Round Pushpin -private const val COORDINATE_PRECISION = 1_000_000L +private const val DEFAULT_EMOJI = 0x1F4CD // Round Pushpin (📍) — same as PIN_EMOJI in GeoJsonConverters +private const val FORMAT_DECIMAL_FACTOR = 1_000_000L /** * Dialog for creating or editing a waypoint on the map. @@ -181,7 +181,7 @@ private fun Double.formatCoord(): String { val negative = this < 0 val absVal = abs(this) val wholePart = absVal.toLong() - val fracPart = ((absVal - wholePart) * COORDINATE_PRECISION + 0.5).toLong() + val fracPart = ((absVal - wholePart) * FORMAT_DECIMAL_FACTOR + 0.5).toLong() val fracStr = fracPart.toString().padStart(6, '0') return "${if (negative) "-" else ""}$wholePart.$fracStr" } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt index b13af0cd3..88d0325a0 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt @@ -32,14 +32,13 @@ import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.OrnamentOptions import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.rememberGeoJsonSource -import org.maplibre.compose.style.BaseStyle import org.maplibre.spatialk.geojson.Feature import org.maplibre.spatialk.geojson.FeatureCollection import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.Node -import org.meshtastic.feature.map.util.COORDINATE_SCALE +import org.meshtastic.feature.map.model.MapStyle import org.meshtastic.feature.map.util.precisionBitsToMeters -import org.maplibre.spatialk.geojson.Position as GeoPosition +import org.meshtastic.feature.map.util.toGeoPositionOrNull private const val DEFAULT_ZOOM = 15.0 private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f @@ -55,27 +54,19 @@ private const val METERS_PER_PIXEL_ZOOM15 = 4.773 @Composable fun InlineMap(node: Node, modifier: Modifier = Modifier) { val position = node.validPosition ?: return - val lat = (position.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (position.longitude_i ?: 0) * COORDINATE_SCALE - if (lat == 0.0 && lng == 0.0) return + val geoPos = toGeoPositionOrNull(position.latitude_i, position.longitude_i) ?: return key(node.num) { - val cameraState = - rememberCameraState( - firstPosition = - CameraPosition(target = GeoPosition(longitude = lng, latitude = lat), zoom = DEFAULT_ZOOM), - ) + val cameraState = rememberCameraState(firstPosition = CameraPosition(target = geoPos, zoom = DEFAULT_ZOOM)) val nodeFeature = - remember(node.num, lat, lng) { - FeatureCollection( - listOf(Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = null)), - ) + remember(node.num, geoPos) { + FeatureCollection(listOf(Feature(geometry = Point(geoPos), properties = null))) } MaplibreMap( modifier = modifier, - baseStyle = BaseStyle.Uri("https://tiles.openfreemap.org/styles/liberty"), + baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), cameraState = cameraState, options = MapOptions(gestureOptions = GestureOptions.AllDisabled, ornamentOptions = OrnamentOptions.AllDisabled), diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt index a8bce5529..b2860f105 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt @@ -24,12 +24,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -/** - * A compact icon button used in map control overlays. Uses [FilledIconButton] for a consistent, compact appearance - * across both Google and F-Droid flavors. - */ +/** A compact icon button used in map control overlays. Uses [FilledIconButton] for a consistent, compact appearance. */ @Composable -fun MapButton( +internal fun MapButton( icon: ImageVector, contentDescription: String, onClick: () -> Unit, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index 346d5bf9d..80032ed0c 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -125,7 +125,6 @@ fun MaplibreMapContent( }, onMapLoadFinished = onMapLoadFinished, onMapLoadFailed = onMapLoadFailed, - onFrame = {}, ) { // --- Terrain hillshade overlay --- if (showHillshade) { diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt index 8fdcf0e33..748e2bcdc 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt @@ -44,7 +44,7 @@ private const val SELECTED_OPACITY = 0.9f * and OSMDroid Polyline overlay implementations. */ @Composable -fun NodeTrackLayers( +internal fun NodeTrackLayers( positions: List, selectedPositionTime: Int? = null, onPositionSelected: ((Int) -> Unit)? = null, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt index 5ea9b5ef6..6f8f34d68 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt @@ -30,9 +30,8 @@ import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.OrnamentOptions import org.maplibre.spatialk.geojson.BoundingBox import org.meshtastic.feature.map.model.MapStyle -import org.meshtastic.feature.map.util.COORDINATE_SCALE +import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.meshtastic.proto.Position -import org.maplibre.spatialk.geojson.Position as GeoPosition private const val DEFAULT_TRACK_ZOOM = 13.0 private const val BOUNDS_PADDING_DP = 48 @@ -53,13 +52,7 @@ fun NodeTrackMap( onPositionSelected: ((Int) -> Unit)? = null, ) { val geoPositions = - remember(positions) { - positions.mapNotNull { pos -> - val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE - if (lat != 0.0 || lng != 0.0) GeoPosition(longitude = lng, latitude = lat) else null - } - } + remember(positions) { positions.mapNotNull { pos -> toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) } } val center = remember(geoPositions) { geoPositions.firstOrNull() } @@ -69,8 +62,8 @@ fun NodeTrackMap( val lats = geoPositions.map { it.latitude } val lngs = geoPositions.map { it.longitude } BoundingBox( - southwest = GeoPosition(longitude = lngs.min(), latitude = lats.min()), - northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()), + southwest = org.maplibre.spatialk.geojson.Position(longitude = lngs.min(), latitude = lats.min()), + northeast = org.maplibre.spatialk.geojson.Position(longitude = lngs.max(), latitude = lats.max()), ) } @@ -78,7 +71,7 @@ fun NodeTrackMap( rememberCameraState( firstPosition = CameraPosition( - target = center ?: GeoPosition(longitude = 0.0, latitude = 0.0), + target = center ?: org.maplibre.spatialk.geojson.Position(longitude = 0.0, latitude = 0.0), zoom = DEFAULT_TRACK_ZOOM, ), ) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt index 45369590e..11a17b912 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt @@ -40,7 +40,8 @@ import org.maplibre.spatialk.geojson.LineString import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.Node import org.meshtastic.core.model.TracerouteOverlay -import org.meshtastic.feature.map.util.COORDINATE_SCALE +import org.meshtastic.feature.map.util.toGeoPositionOrNull +import org.meshtastic.feature.map.util.typedFeatureCollection import org.maplibre.spatialk.geojson.Position as GeoPosition private val ForwardRouteColor = Color(0xFF4CAF50) @@ -54,7 +55,7 @@ private const val ROUTE_OPACITY = 0.8f * polyline implementations. */ @Composable -fun TracerouteLayers( +internal fun TracerouteLayers( overlay: TracerouteOverlay?, nodePositions: Map, nodes: Map, @@ -130,9 +131,7 @@ private fun buildTracerouteGeoJson( ): TracerouteGeoJsonData { fun nodeToGeoPosition(nodeNum: Int): GeoPosition? { val pos = nodePositions[nodeNum] ?: return null - val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE - return if (lat == 0.0 && lng == 0.0) null else GeoPosition(longitude = lng, latitude = lat) + return toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) } // Build forward route line @@ -144,11 +143,9 @@ private fun buildTracerouteGeoJson( geometry = LineString(forwardCoords), properties = buildJsonObject { put("direction", "forward") }, ) - @Suppress("UNCHECKED_CAST") - FeatureCollection(listOf(feature)) as FeatureCollection + typedFeatureCollection(listOf(feature)) } else { - @Suppress("UNCHECKED_CAST") - FeatureCollection(emptyList>()) as FeatureCollection + typedFeatureCollection(emptyList>()) } // Build return route line @@ -160,11 +157,9 @@ private fun buildTracerouteGeoJson( geometry = LineString(returnCoords), properties = buildJsonObject { put("direction", "return") }, ) - @Suppress("UNCHECKED_CAST") - FeatureCollection(listOf(feature)) as FeatureCollection + typedFeatureCollection(listOf(feature)) } else { - @Suppress("UNCHECKED_CAST") - FeatureCollection(emptyList>()) as FeatureCollection + typedFeatureCollection(emptyList>()) } // Build hop marker points @@ -185,10 +180,9 @@ private fun buildTracerouteGeoJson( ) } - @Suppress("UNCHECKED_CAST") return TracerouteGeoJsonData( forwardLine = forwardLine, returnLine = returnLine, - hopFeatures = FeatureCollection(hopFeatures) as FeatureCollection, + hopFeatures = typedFeatureCollection(hopFeatures), ) } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt index 4f1a69309..042ad8365 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt @@ -32,9 +32,8 @@ import org.maplibre.spatialk.geojson.BoundingBox import org.meshtastic.core.model.Node import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.feature.map.model.MapStyle -import org.meshtastic.feature.map.util.COORDINATE_SCALE +import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.meshtastic.proto.Position -import org.maplibre.spatialk.geojson.Position as GeoPosition private const val DEFAULT_TRACEROUTE_ZOOM = 10.0 private const val BOUNDS_PADDING_DP = 64 @@ -60,11 +59,7 @@ fun TracerouteMap( ) { val geoPositions = remember(tracerouteNodePositions) { - tracerouteNodePositions.values.mapNotNull { pos -> - val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE - if (lat != 0.0 || lng != 0.0) GeoPosition(longitude = lng, latitude = lat) else null - } + tracerouteNodePositions.values.mapNotNull { pos -> toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) } } val center = remember(geoPositions) { geoPositions.firstOrNull() } @@ -75,8 +70,8 @@ fun TracerouteMap( val lats = geoPositions.map { it.latitude } val lngs = geoPositions.map { it.longitude } BoundingBox( - southwest = GeoPosition(longitude = lngs.min(), latitude = lats.min()), - northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()), + southwest = org.maplibre.spatialk.geojson.Position(longitude = lngs.min(), latitude = lats.min()), + northeast = org.maplibre.spatialk.geojson.Position(longitude = lngs.max(), latitude = lats.max()), ) } @@ -84,7 +79,7 @@ fun TracerouteMap( rememberCameraState( firstPosition = CameraPosition( - target = center ?: GeoPosition(longitude = 0.0, latitude = 0.0), + target = center ?: org.maplibre.spatialk.geojson.Position(longitude = 0.0, latitude = 0.0), zoom = DEFAULT_TRACEROUTE_ZOOM, ), ) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/di/FeatureMapModule.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/di/FeatureMapModule.kt index a6ff74b17..d8fdd4e78 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/di/FeatureMapModule.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/di/FeatureMapModule.kt @@ -19,6 +19,7 @@ package org.meshtastic.feature.map.di import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module +/** Koin module for the map feature. Scans [org.meshtastic.feature.map] for annotated dependencies. */ @Module @ComponentScan("org.meshtastic.feature.map") class FeatureMapModule diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt index 82572ef8d..1d26cff85 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt @@ -18,11 +18,13 @@ package org.meshtastic.feature.map.model import kotlin.uuid.Uuid +/** Supported custom overlay layer formats. */ enum class LayerType { KML, GEOJSON, } +/** A user-importable map overlay layer (KML or GeoJSON file). */ data class MapLayerItem( val id: String = Uuid.random().toString(), val name: String, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt index ee786764c..b013ef785 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt @@ -25,6 +25,7 @@ import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.feature.map.MapScreen import org.meshtastic.feature.map.MapViewModel +/** Registers the map feature's navigation entries into a Navigation 3 [EntryProviderScope]. */ fun EntryProviderScope.mapGraph(backStack: NavBackStack) { entry { args -> val viewModel = koinViewModel() diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt index ea37d1008..f90b6bb9d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt @@ -28,9 +28,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.toList import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.model.MeshLog -import org.meshtastic.core.repository.MapPrefs import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.ui.util.toPosition @@ -43,8 +41,6 @@ class NodeMapViewModel( savedStateHandle: SavedStateHandle, nodeRepository: NodeRepository, meshLogRepository: MeshLogRepository, - buildConfigProvider: BuildConfigProvider, - private val mapPrefs: MapPrefs, ) : ViewModel() { private val destNumFromRoute = savedStateHandle.get("destNum") private val manualDestNum = MutableStateFlow(null) @@ -52,18 +48,12 @@ class NodeMapViewModel( private val destNumFlow = combine(MutableStateFlow(destNumFromRoute), manualDestNum) { route, manual -> manual ?: route ?: 0 } - fun setDestNum(num: Int) { - manualDestNum.value = num - } - val node = destNumFlow .flatMapLatest { destNum -> nodeRepository.nodeDBbyNum.mapLatest { it[destNum] } } .distinctUntilChanged() .stateInWhileSubscribed(initialValue = null) - val applicationId = buildConfigProvider.applicationId - private val ourNodeNumFlow = nodeRepository.myNodeInfo.map { it?.myNodeNum }.distinctUntilChanged() val positionLogs: StateFlow> = @@ -84,7 +74,4 @@ class NodeMapViewModel( } } .stateInWhileSubscribed(initialValue = emptyList()) - - val mapStyleId: Int - get() = mapPrefs.mapStyle.value } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt index cbff63e81..9bf687512 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt @@ -21,11 +21,11 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import org.maplibre.spatialk.geojson.Feature import org.maplibre.spatialk.geojson.FeatureCollection +import org.maplibre.spatialk.geojson.Geometry import org.maplibre.spatialk.geojson.LineString import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node -import org.maplibre.spatialk.geojson.Position as GeoPosition private const val MIN_PRECISION_BITS = 10 private const val MAX_PRECISION_BITS = 19 @@ -35,9 +35,7 @@ fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): Feature val features = nodes.mapNotNull { node -> val pos = node.validPosition ?: return@mapNotNull null - val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE - if (lat == 0.0 && lng == 0.0) return@mapNotNull null + val geoPos = toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) ?: return@mapNotNull null val colors = node.colors val props = buildJsonObject { @@ -57,11 +55,10 @@ fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): Feature put("precision_meters", precisionBitsToMeters(pos.precision_bits ?: 0)) } - Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props) + Feature(geometry = Point(geoPos), properties = props) } - @Suppress("UNCHECKED_CAST") - return FeatureCollection(features) as FeatureCollection + return typedFeatureCollection(features) } /** Convert waypoints to a GeoJSON [FeatureCollection]. */ @@ -69,9 +66,7 @@ fun waypointsToFeatureCollection(waypoints: Map): FeatureCollec val features = waypoints.values.mapNotNull { packet -> val waypoint = packet.waypoint ?: return@mapNotNull null - val lat = (waypoint.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (waypoint.longitude_i ?: 0) * COORDINATE_SCALE - if (lat == 0.0 && lng == 0.0) return@mapNotNull null + val geoPos = toGeoPositionOrNull(waypoint.latitude_i, waypoint.longitude_i) ?: return@mapNotNull null val emoji = if (waypoint.icon != 0) convertIntToEmoji(waypoint.icon) else PIN_EMOJI @@ -85,21 +80,15 @@ fun waypointsToFeatureCollection(waypoints: Map): FeatureCollec put("expire", waypoint.expire) } - Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props) + Feature(geometry = Point(geoPos), properties = props) } - @Suppress("UNCHECKED_CAST") - return FeatureCollection(features) as FeatureCollection + return typedFeatureCollection(features) } /** Convert position history to a GeoJSON [LineString] for track rendering. */ fun positionsToLineString(positions: List): FeatureCollection { - val coords = - positions.mapNotNull { pos -> - val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE - if (lat == 0.0 && lng == 0.0) null else GeoPosition(longitude = lng, latitude = lat) - } + val coords = positions.mapNotNull { pos -> toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) } if (coords.size < 2) return FeatureCollection(emptyList()) @@ -107,17 +96,14 @@ fun positionsToLineString(positions: List): Featu val feature = Feature(geometry = LineString(coords), properties = props) - @Suppress("UNCHECKED_CAST") - return FeatureCollection(listOf(feature)) as FeatureCollection + return typedFeatureCollection(listOf(feature)) } /** Convert position history to individual point features with time metadata. */ fun positionsToPointFeatures(positions: List): FeatureCollection { val features = positions.mapNotNull { pos -> - val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE - if (lat == 0.0 && lng == 0.0) return@mapNotNull null + val geoPos = toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) ?: return@mapNotNull null val props = buildJsonObject { put("time", (pos.time ?: 0).toString()) @@ -126,11 +112,10 @@ fun positionsToPointFeatures(positions: List): Fe put("sats_in_view", pos.sats_in_view ?: 0) } - Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props) + Feature(geometry = Point(geoPos), properties = props) } - @Suppress("UNCHECKED_CAST") - return FeatureCollection(features) as FeatureCollection + return typedFeatureCollection(features) } /** Approximate meters of positional uncertainty from precision_bits (10-19). */ @@ -149,7 +134,16 @@ fun precisionBitsToMeters(precisionBits: Int): Double = when (precisionBits) { else -> 0.0 } -private const val PIN_EMOJI = "\uD83D\uDCCD" +private const val PIN_EMOJI = "\uD83D\uDCCD" // U+1F4CD Round Pushpin — same as DEFAULT_EMOJI in EditWaypointDialog + +/** + * Wraps [FeatureCollection] constructor with an unchecked cast to the desired type parameters. Centralizes the single + * unavoidable cast required by the spatialk GeoJSON API. + */ +@Suppress("UNCHECKED_CAST") +internal fun typedFeatureCollection(features: List>): FeatureCollection = + FeatureCollection(features) as FeatureCollection + private const val BMP_MAX = 0xFFFF private const val SUPPLEMENTARY_OFFSET = 0x10000 private const val HALF_SHIFT = 10 diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt index cdecf8ecd..59325300d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt @@ -16,5 +16,17 @@ */ package org.meshtastic.feature.map.util +import org.maplibre.spatialk.geojson.Position as GeoPosition + /** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */ const val COORDINATE_SCALE = 1e-7 + +/** + * Convert Meshtastic integer microdegree coordinates to a [GeoPosition], returning `null` if both latitude and + * longitude are zero (indicating no valid position). + */ +fun toGeoPositionOrNull(latI: Int?, lngI: Int?): GeoPosition? { + val lat = (latI ?: 0) * COORDINATE_SCALE + val lng = (lngI ?: 0) * COORDINATE_SCALE + return if (lat == 0.0 && lng == 0.0) null else GeoPosition(longitude = lng, latitude = lat) +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt index fea1de0e0..d9d0629aa 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt @@ -276,6 +276,36 @@ class BaseMapViewModelTest { } } + @Test + fun filteredNodes_combinedFavoritesAndLastHeard_filtersCorrectly() = runTest(testDispatcher) { + val now = nowSeconds.toInt() + val myNodeNum = 1 + nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(myNodeNum = myNodeNum)) + val nodes = + listOf( + nodeWithPosition(myNodeNum, lastHeard = now), // my node — always visible + nodeWithPosition(2, isFavorite = true, lastHeard = now), // favorite + recent + nodeWithPosition(3, isFavorite = true, lastHeard = now - 7200), // favorite + stale + nodeWithPosition(4, isFavorite = false, lastHeard = now), // non-favorite + recent + ) + nodeRepository.setNodes(nodes) + + // Enable both filters + viewModel.toggleOnlyFavorites() + viewModel.setLastHeardFilter(LastHeardFilter.OneHour) + + viewModel.filteredNodes.test { + val result = expectMostRecentItem() + val nodeNums = result.map { it.num }.toSet() + // My node always visible, favorite+recent visible, favorite+stale filtered, non-favorite filtered + assertTrue(myNodeNum in nodeNums, "My node should always be visible") + assertTrue(2 in nodeNums, "Favorite + recent node should be visible") + assertFalse(3 in nodeNums, "Favorite + stale node should be filtered out by lastHeard") + assertFalse(4 in nodeNums, "Non-favorite node should be filtered out by favorites filter") + cancelAndIgnoreRemainingEvents() + } + } + // ---- getNodeOrFallback ---- @Test diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt new file mode 100644 index 000000000..dc36f1c9a --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 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 . + */ +package org.meshtastic.feature.map.model + +import org.maplibre.compose.style.BaseStyle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class MapStyleTest { + + @Test + fun toBaseStyle_returnsUriWithCorrectStyleUri() { + for (style in MapStyle.entries) { + val baseStyle = style.toBaseStyle() + assertIs(baseStyle) + assertEquals(style.styleUri, baseStyle.uri) + } + } + + @Test + fun allStyles_haveNonBlankUri() { + for (style in MapStyle.entries) { + assert(style.styleUri.isNotBlank()) { "${style.name} has a blank styleUri" } + } + } + + @Test + fun openStreetMap_isDefault() { + // Verify OpenStreetMap is the first entry (used as default throughout the app) + assertEquals(MapStyle.OpenStreetMap, MapStyle.entries.first()) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt index 5ce345214..df1de8fe5 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt @@ -16,6 +16,8 @@ */ package org.meshtastic.feature.map.util +import org.maplibre.spatialk.geojson.Feature +import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.proto.Position @@ -23,6 +25,8 @@ import org.meshtastic.proto.User import org.meshtastic.proto.Waypoint import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue @Suppress("MagicNumber") @@ -272,4 +276,57 @@ class GeoJsonConvertersTest { val result = convertIntToEmoji(0xFFFF) assertEquals(1, result.length) } + + @Test + fun convertIntToEmoji_negativeCodepoint_returnsNonEmptyString() { + // Negative code points wrap around in char conversion but should not crash + val result = convertIntToEmoji(-1) + assertTrue(result.isNotEmpty(), "Should return a non-empty string even for invalid code points") + } + + // --- toGeoPositionOrNull --- + + @Test + fun toGeoPositionOrNull_validCoords_returnsGeoPosition() { + val result = toGeoPositionOrNull(400000000, -740000000) + assertNotNull(result) + assertEquals(40.0, result.latitude, 0.001) + assertEquals(-74.0, result.longitude, 0.001) + } + + @Test + fun toGeoPositionOrNull_zeroCoords_returnsNull() { + val result = toGeoPositionOrNull(0, 0) + assertNull(result) + } + + @Test + fun toGeoPositionOrNull_nullCoords_returnsNull() { + val result = toGeoPositionOrNull(null, null) + assertNull(result) + } + + @Test + fun toGeoPositionOrNull_onlyLatNull_treatedAsZero() { + // null lat = 0, non-zero lng -> lat=0.0 && lng!=0.0 -> not both zero -> returns position + val result = toGeoPositionOrNull(null, 100000000) + assertNotNull(result) + assertEquals(0.0, result.latitude, 0.001) + assertEquals(10.0, result.longitude, 0.001) + } + + // --- typedFeatureCollection --- + + @Test + fun typedFeatureCollection_preservesFeatures() { + val features = + listOf( + Feature( + geometry = Point(org.maplibre.spatialk.geojson.Position(longitude = 1.0, latitude = 2.0)), + properties = null, + ), + ) + val result = typedFeatureCollection(features) + assertEquals(1, result.features.size) + } }