diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0942756c0..d93686e92 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -277,11 +277,6 @@ dependencies { debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.glance.preview) - googleImplementation(libs.location.services) - googleImplementation(libs.play.services.maps) - googleImplementation(libs.maps.compose) - googleImplementation(libs.maps.compose.utils) - googleImplementation(libs.maps.compose.widgets) googleImplementation(libs.dd.sdk.android.compose) googleImplementation(libs.dd.sdk.android.logs) googleImplementation(libs.dd.sdk.android.rum) @@ -294,10 +289,6 @@ dependencies { googleImplementation(libs.firebase.analytics) googleImplementation(libs.firebase.crashlytics) - fdroidImplementation(libs.osmdroid.android) - fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") } - fdroidImplementation(libs.osmbonuspack) - testImplementation(kotlin("test-junit")) testImplementation(libs.androidx.work.testing) testImplementation(libs.koin.test) diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt deleted file mode 100644 index 21c2d4fde..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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.app.map - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.annotation.Single -import org.meshtastic.core.ui.util.MapViewProvider - -/** OSMDroid implementation of [MapViewProvider]. */ -@Single -class FdroidMapViewProvider : MapViewProvider { - @Composable - override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { - val mapViewModel: MapViewModel = koinViewModel() - LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) } - org.meshtastic.app.map.MapView( - modifier = modifier, - mapViewModel = mapViewModel, - navigateToNodeDetails = navigateToNodeDetails, - ) - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt deleted file mode 100644 index 48b1aa7fc..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * 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.app.map - -import org.meshtastic.core.ui.util.MapViewProvider - -fun getMapViewProvider(): MapViewProvider = FdroidMapViewProvider() diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt deleted file mode 100644 index 1243fdc8a..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map - -import android.content.Context -import android.util.TypedValue -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import kotlin.math.log2 -import kotlin.math.pow - -private const val DEGREES_IN_CIRCLE = 360.0 -private const val METERS_PER_DEGREE_LATITUDE = 111320.0 -private const val ZOOM_ADJUSTMENT_FACTOR = 0.8 - -/** - * Calculates the zoom level required to fit the entire [BoundingBox] inside the map view. - * - * @return The zoom level as a Double value. - */ -fun BoundingBox.requiredZoomLevel(): Double { - val topLeft = GeoPoint(this.latNorth, this.lonWest) - val bottomRight = GeoPoint(this.latSouth, this.lonEast) - val latLonWidth = topLeft.distanceToAsDouble(GeoPoint(topLeft.latitude, bottomRight.longitude)) - val latLonHeight = topLeft.distanceToAsDouble(GeoPoint(bottomRight.latitude, topLeft.longitude)) - val requiredLatZoom = log2(DEGREES_IN_CIRCLE / (latLonHeight / METERS_PER_DEGREE_LATITUDE)) - val requiredLonZoom = log2(DEGREES_IN_CIRCLE / (latLonWidth / METERS_PER_DEGREE_LATITUDE)) - return maxOf(requiredLatZoom, requiredLonZoom) * ZOOM_ADJUSTMENT_FACTOR -} - -/** - * Creates a new bounding box with adjusted dimensions based on the provided [zoomFactor]. - * - * @return A new [BoundingBox] with added [zoomFactor]. Example: - * ``` - * // Setting the zoom level directly using setZoom() - * map.setZoom(14.0) - * val boundingBoxZoom14 = map.boundingBox - * - * // Using zoomIn() results the equivalent BoundingBox with setZoom(15.0) - * val boundingBoxZoom15 = boundingBoxZoom14.zoomIn(1.0) - * ``` - */ -fun BoundingBox.zoomIn(zoomFactor: Double): BoundingBox { - val center = GeoPoint((latNorth + latSouth) / 2, (lonWest + lonEast) / 2) - val latDiff = latNorth - latSouth - val lonDiff = lonEast - lonWest - - val newLatDiff = latDiff / (2.0.pow(zoomFactor)) - val newLonDiff = lonDiff / (2.0.pow(zoomFactor)) - - return BoundingBox( - center.latitude + newLatDiff / 2, - center.longitude + newLonDiff / 2, - center.latitude - newLatDiff / 2, - center.longitude - newLonDiff / 2, - ) -} - -// Converts SP to pixels. -fun Context.spToPx(sp: Float): Int = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, resources.displayMetrics).toInt() - -// Converts DP to pixels. -fun Context.dpToPx(dp: Float): Int = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics).toInt() diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt deleted file mode 100644 index 657f7ab74..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ /dev/null @@ -1,968 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map - -import android.Manifest -import androidx.appcompat.content.res.AppCompatResources -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialogDefaults -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableDoubleStateOf -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateListOf -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.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import co.touchlab.kermit.Logger -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.rememberMultiplePermissionsState -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.app.R -import org.meshtastic.app.map.cluster.RadiusMarkerClusterer -import org.meshtastic.app.map.component.CacheLayout -import org.meshtastic.app.map.component.DownloadButton -import org.meshtastic.app.map.component.EditWaypointDialog -import org.meshtastic.app.map.model.CustomTileSource -import org.meshtastic.app.map.model.MarkerWithLabel -import org.meshtastic.core.common.gpsDisabled -import org.meshtastic.core.common.util.DateFormatter -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.Node -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.calculating -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.clear -import org.meshtastic.core.resources.close -import org.meshtastic.core.resources.delete_for_everyone -import org.meshtastic.core.resources.delete_for_me -import org.meshtastic.core.resources.expires -import org.meshtastic.core.resources.getString -import org.meshtastic.core.resources.last_heard_filter_label -import org.meshtastic.core.resources.location_disabled -import org.meshtastic.core.resources.map_cache_info -import org.meshtastic.core.resources.map_cache_manager -import org.meshtastic.core.resources.map_cache_size -import org.meshtastic.core.resources.map_cache_tiles -import org.meshtastic.core.resources.map_clear_tiles -import org.meshtastic.core.resources.map_download_complete -import org.meshtastic.core.resources.map_download_errors -import org.meshtastic.core.resources.map_download_region -import org.meshtastic.core.resources.map_node_popup_details -import org.meshtastic.core.resources.map_offline_manager -import org.meshtastic.core.resources.map_purge_fail -import org.meshtastic.core.resources.map_purge_success -import org.meshtastic.core.resources.map_style_selection -import org.meshtastic.core.resources.map_subDescription -import org.meshtastic.core.resources.map_tile_source -import org.meshtastic.core.resources.only_favorites -import org.meshtastic.core.resources.show_precision_circle -import org.meshtastic.core.resources.show_waypoints -import org.meshtastic.core.resources.waypoint_delete -import org.meshtastic.core.resources.you -import org.meshtastic.core.ui.component.BasicListItem -import org.meshtastic.core.ui.component.ListItem -import org.meshtastic.core.ui.icon.Check -import org.meshtastic.core.ui.icon.Favorite -import org.meshtastic.core.ui.icon.Layers -import org.meshtastic.core.ui.icon.Lens -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.PinDrop -import org.meshtastic.core.ui.util.formatAgo -import org.meshtastic.core.ui.util.showToast -import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState -import org.meshtastic.feature.map.LastHeardFilter -import org.meshtastic.feature.map.component.MapButton -import org.meshtastic.feature.map.component.MapControlsOverlay -import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits -import org.meshtastic.proto.Waypoint -import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable -import org.osmdroid.config.Configuration -import org.osmdroid.events.MapEventsReceiver -import org.osmdroid.events.MapListener -import org.osmdroid.events.ScrollEvent -import org.osmdroid.events.ZoomEvent -import org.osmdroid.tileprovider.cachemanager.CacheManager -import org.osmdroid.tileprovider.modules.SqliteArchiveTileWriter -import org.osmdroid.tileprovider.tilesource.ITileSource -import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase -import org.osmdroid.tileprovider.tilesource.TileSourcePolicyException -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.MapEventsOverlay -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.Polygon -import org.osmdroid.views.overlay.infowindow.InfoWindow -import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay -import java.io.File -import kotlin.math.roundToInt - -private fun MapView.updateMarkers( - nodeMarkers: List, - waypointMarkers: List, - nodeClusterer: RadiusMarkerClusterer, -) { - Logger.d { "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints" } - - overlays.removeAll { overlay -> - overlay is MarkerWithLabel || (overlay is Marker && overlay !in nodeClusterer.items) - } - - overlays.addAll(waypointMarkers) - - nodeClusterer.items.clear() - nodeClusterer.items.addAll(nodeMarkers) - nodeClusterer.invalidate() -} - -private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) -> Unit) = - object : CacheManager.CacheManagerCallback { - override fun onTaskComplete() { - onTaskComplete() - } - - override fun onTaskFailed(errors: Int) { - onTaskFailed(errors) - } - - override fun updateProgress(progress: Int, currentZoomLevel: Int, zoomMin: Int, zoomMax: Int) { - // NOOP since we are using the build in UI - } - - override fun downloadStarted() { - // NOOP since we are using the build in UI - } - - override fun setPossibleTilesInArea(total: Int) { - // NOOP since we are using the build in UI - } - } - -/** - * Main composable for displaying the map view, including nodes, waypoints, and user location. It handles user - * interactions for map manipulation, filtering, and offline caching. - * - * @param mapViewModel The [MapViewModel] providing data and state for the map. - * @param navigateToNodeDetails Callback to navigate to the details screen of a selected node. - */ -@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist -@Suppress("CyclomaticComplexMethod", "LongMethod") -@Composable -fun MapView( - modifier: Modifier = Modifier, - mapViewModel: MapViewModel = koinViewModel(), - navigateToNodeDetails: (Int) -> Unit, -) { - var mapFilterExpanded by remember { mutableStateOf(false) } - - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() - - var cacheEstimate by remember { mutableStateOf("") } - - var zoomLevelMin by remember { mutableDoubleStateOf(0.0) } - var zoomLevelMax by remember { mutableDoubleStateOf(0.0) } - - var downloadRegionBoundingBox: BoundingBox? by remember { mutableStateOf(null) } - var myLocationOverlay: MyLocationNewOverlay? by remember { mutableStateOf(null) } - - var showDownloadButton: Boolean by remember { mutableStateOf(false) } - var showEditWaypointDialog by remember { mutableStateOf(null) } - var showCacheManagerDialog by remember { mutableStateOf(false) } - var showCurrentCacheInfo by remember { mutableStateOf(false) } - var showPurgeTileSourceDialog by remember { mutableStateOf(false) } - var showMapStyleDialog by remember { mutableStateOf(false) } - - val scope = rememberCoroutineScope() - val context = LocalContext.current - val density = LocalDensity.current - - val haptic = LocalHapticFeedback.current - fun performHapticFeedback() = haptic.performHapticFeedback(HapticFeedbackType.LongPress) - - // Accompanist permissions state for location - val locationPermissionsState = - rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) - var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) } - - fun loadOnlineTileSourceBase(): ITileSource { - val id = mapViewModel.mapStyleId - Logger.d { "mapStyleId from prefs: $id" } - return CustomTileSource.getTileSource(id).also { - zoomLevelMax = it.maximumZoomLevel.toDouble() - showDownloadButton = if (it is OnlineTileSourceBase) it.tileSourcePolicy.acceptsBulkDownload() else false - } - } - - val initialCameraView = remember { - val nodes = mapViewModel.nodes.value - val nodesWithPosition = nodes.filter { it.validPosition != null } - val geoPoints = nodesWithPosition.map { GeoPoint(it.latitude, it.longitude) } - BoundingBox.fromGeoPoints(geoPoints) - } - val map = - rememberMapViewWithLifecycle( - applicationId = mapViewModel.applicationId, - box = initialCameraView, - tileSource = loadOnlineTileSourceBase(), - ) - - val nodeClusterer = remember { RadiusMarkerClusterer(context) } - - fun MapView.toggleMyLocation() { - if (context.gpsDisabled()) { - Logger.d { "Telling user we need location turned on for MyLocationNewOverlay" } - scope.launch { context.showToast(Res.string.location_disabled) } - return - } - - Logger.d { "user clicked MyLocationNewOverlay ${myLocationOverlay == null}" } - if (myLocationOverlay == null) { - myLocationOverlay = - MyLocationNewOverlay(this).apply { - enableMyLocation() - enableFollowLocation() - getBitmapFromVectorDrawable(context, R.drawable.ic_map_location_dot)?.let { - setPersonIcon(it) - setPersonAnchor(0.5f, 0.5f) - } - getBitmapFromVectorDrawable(context, R.drawable.ic_map_navigation)?.let { - setDirectionIcon(it) - setDirectionAnchor(0.5f, 0.5f) - } - } - overlays.add(myLocationOverlay) - } else { - myLocationOverlay?.apply { - disableMyLocation() - disableFollowLocation() - } - overlays.remove(myLocationOverlay) - myLocationOverlay = null - } - } - - // Effect to toggle MyLocation after permission is granted - LaunchedEffect(locationPermissionsState.allPermissionsGranted) { - if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) { - map.toggleMyLocation() - triggerLocationToggleAfterPermission = false - } - } - - // Keep screen on while location tracking is active - LaunchedEffect(myLocationOverlay) { - val activity = context as? android.app.Activity ?: return@LaunchedEffect - if (myLocationOverlay != null) { - activity.window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - activity.window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } - - val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() - val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) - val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle() - val myId by mapViewModel.myId.collectAsStateWithLifecycle() - - LaunchedEffect(selectedWaypointId, waypoints) { - if (selectedWaypointId != null && waypoints.containsKey(selectedWaypointId)) { - waypoints[selectedWaypointId]?.waypoint?.let { pt -> - val geoPoint = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7) - map.controller.setCenter(geoPoint) - map.controller.setZoom(WAYPOINT_ZOOM) - } - } - } - - val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) } - - fun MapView.onNodesChanged(nodes: Collection): List { - val nodesWithPosition = nodes.filter { it.validPosition != null } - val ourNode = mapViewModel.ourNodeInfo.value - val displayUnits = mapViewModel.config.display?.units ?: DisplayUnits.METRIC - val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly - return nodesWithPosition.mapNotNull { node -> - if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) { - return@mapNotNull null - } - if ( - mapFilterStateValue.lastHeardFilter.seconds != 0L && - (nowSeconds - node.lastHeard) > mapFilterStateValue.lastHeardFilter.seconds && - node.num != ourNode?.num - ) { - return@mapNotNull null - } - - val (p, u) = node.position to node.user - val nodePosition = GeoPoint(node.latitude, node.longitude) - MarkerWithLabel(mapView = this, label = "${u.short_name} ${formatAgo(p.time)}").apply { - id = u.id - title = u.long_name - snippet = - getString( - Res.string.map_node_popup_details, - node.gpsString(), - formatAgo(node.lastHeard), - formatAgo(p.time), - if (node.batteryStr != "") node.batteryStr else "?", - ) - ourNode?.distanceStr(node, displayUnits)?.let { dist -> - ourNode.bearing(node)?.let { bearing -> - subDescription = getString(Res.string.map_subDescription, bearing, dist) - } - } - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - position = nodePosition - icon = markerIcon - setNodeColors(node.colors) - if (!mapFilterStateValue.showPrecisionCircle) { - setPrecisionBits(0) - } else { - setPrecisionBits(p.precision_bits) - } - setOnLongClickListener { - navigateToNodeDetails(node.num) - true - } - } - } - } - - fun showDeleteMarkerDialog(waypoint: Waypoint) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(getString(Res.string.waypoint_delete)) - builder.setNeutralButton(getString(Res.string.cancel)) { _, _ -> - Logger.d { "User canceled marker delete dialog" } - } - builder.setNegativeButton(getString(Res.string.delete_for_me)) { _, _ -> - Logger.d { "User deleted waypoint ${waypoint.id} for me" } - mapViewModel.deleteWaypoint(waypoint.id) - } - if (waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) { - builder.setPositiveButton(getString(Res.string.delete_for_everyone)) { _, _ -> - Logger.d { "User deleted waypoint ${waypoint.id} for everyone" } - mapViewModel.sendWaypoint(waypoint.copy(expire = 1)) - mapViewModel.deleteWaypoint(waypoint.id) - } - } - val dialog = builder.show() - for ( - button in - setOf( - androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL, - androidx.appcompat.app.AlertDialog.BUTTON_NEGATIVE, - androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE, - ) - ) { - with(dialog.getButton(button)) { - textSize = 12F - isAllCaps = false - } - } - } - - fun showMarkerLongPressDialog(id: Int) { - performHapticFeedback() - Logger.d { "marker long pressed id=$id" } - val waypoint = waypoints[id]?.waypoint ?: return - // edit only when unlocked or lockedTo myNodeNum - if (waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) { - showEditWaypointDialog = waypoint - } else { - showDeleteMarkerDialog(waypoint) - } - } - - fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL || (myId != null && id == myId)) { - getString(Res.string.you) - } else { - mapViewModel.getUser(id).long_name - } - - @Suppress("MagicNumber") - fun MapView.onWaypointChanged(waypoints: Collection, selectedWaypointId: Int?): List { - return waypoints.mapNotNull { waypoint -> - val pt = waypoint.waypoint ?: return@mapNotNull null - if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState - val lock = if (pt.locked_to != 0) "\uD83D\uDD12" else "" - val time = DateFormatter.formatDateTime(waypoint.time) - val label = pt.name + " " + formatAgo((waypoint.time / 1000).toInt()) - val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon)) - val now = nowMillis - val expireTimeMillis = pt.expire * 1000L - val expireTimeStr = - when { - pt.expire == 0 || pt.expire == Int.MAX_VALUE -> "Never" - expireTimeMillis <= now -> "Expired" - else -> DateFormatter.formatRelativeTime(expireTimeMillis) - } - MarkerWithLabel(this, label, emoji).apply { - id = "${pt.id}" - title = "${pt.name} (${getUsername(waypoint.from)}$lock)" - snippet = "[$time] ${pt.description} " + getString(Res.string.expires) + ": $expireTimeStr" - position = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7) - if (selectedWaypointId == pt.id) { - showInfoWindow() - } - setOnLongClickListener { - showMarkerLongPressDialog(pt.id) - true - } - } - } - } - - val mapEventsReceiver = - object : MapEventsReceiver { - override fun singleTapConfirmedHelper(p: GeoPoint): Boolean { - InfoWindow.closeAllInfoWindowsOn(map) - return true - } - - override fun longPressHelper(p: GeoPoint): Boolean { - performHapticFeedback() - val enabled = isConnected && downloadRegionBoundingBox == null - - if (enabled) { - showEditWaypointDialog = - Waypoint(latitude_i = (p.latitude * 1e7).toInt(), longitude_i = (p.longitude * 1e7).toInt()) - } - return true - } - } - - fun MapView.drawOverlays() { - if (overlays.none { it is MapEventsOverlay }) { - overlays.add(0, MapEventsOverlay(mapEventsReceiver)) - } - if (myLocationOverlay != null && overlays.none { it is MyLocationNewOverlay }) { - overlays.add(myLocationOverlay) - } - if (overlays.none { it is RadiusMarkerClusterer }) { - overlays.add(nodeClusterer) - } - - addCopyright() - addScaleBarOverlay(density) - createLatLongGrid(false) - - invalidate() - } - - fun MapView.generateBoxOverlay() { - overlays.removeAll { it is Polygon } - val zoomFactor = 1.3 - zoomLevelMin = minOf(zoomLevelDouble, zoomLevelMax) - downloadRegionBoundingBox = boundingBox.zoomIn(zoomFactor) - val polygon = - Polygon().apply { - points = Polygon.pointsAsRect(downloadRegionBoundingBox).map { GeoPoint(it.latitude, it.longitude) } - } - overlays.add(polygon) - invalidate() - val tileCount: Int = - CacheManager(this) - .possibleTilesInArea(downloadRegionBoundingBox, zoomLevelMin.toInt(), zoomLevelMax.toInt()) - cacheEstimate = getString(Res.string.map_cache_tiles, tileCount) - } - - val boxOverlayListener = - object : MapListener { - override fun onScroll(event: ScrollEvent): Boolean { - if (downloadRegionBoundingBox != null) { - event.source.generateBoxOverlay() - } - return true - } - - override fun onZoom(event: ZoomEvent): Boolean = false - } - - fun startDownload() { - val boundingBox = downloadRegionBoundingBox ?: return - try { - val outputName = buildString { - append(Configuration.getInstance().osmdroidBasePath.absolutePath) - append(File.separator) - append("mainFile.sqlite") - } - val writer = SqliteArchiveTileWriter(outputName) - val cacheManager = CacheManager(map, writer) - cacheManager.downloadAreaAsync( - context, - boundingBox, - zoomLevelMin.toInt(), - zoomLevelMax.toInt(), - cacheManagerCallback( - onTaskComplete = { - scope.launch { context.showToast(Res.string.map_download_complete) } - writer.onDetach() - }, - onTaskFailed = { errors -> - scope.launch { context.showToast(Res.string.map_download_errors, errors) } - writer.onDetach() - }, - ), - ) - } catch (ex: TileSourcePolicyException) { - Logger.d { "Tile source does not allow archiving: ${ex.message}" } - } catch (ex: Exception) { - Logger.d { "Tile source exception: ${ex.message}" } - } - } - - Scaffold( - modifier = modifier, - floatingActionButton = { - DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { showCacheManagerDialog = true } - }, - ) { innerPadding -> - Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { - AndroidView( - factory = { - map.apply { - setDestroyMode(false) - addMapListener(boxOverlayListener) - } - }, - modifier = Modifier.fillMaxSize(), - update = { mapView -> - with(mapView) { - updateMarkers( - onNodesChanged(nodes), - onWaypointChanged(waypoints.values, selectedWaypointId), - nodeClusterer, - ) - } - mapView.drawOverlays() - }, // Renamed map to mapView to avoid conflict - ) - if (downloadRegionBoundingBox != null) { - CacheLayout( - cacheEstimate = cacheEstimate, - onExecuteJob = { startDownload() }, - onCancelDownload = { - downloadRegionBoundingBox = null - map.overlays.removeAll { it is Polygon } - map.invalidate() - }, - modifier = Modifier.align(Alignment.BottomCenter), - ) - } else { - MapControlsOverlay( - modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), - onToggleFilterMenu = { mapFilterExpanded = true }, - filterDropdownContent = { - FdroidMainMapFilterDropdown( - expanded = mapFilterExpanded, - onDismissRequest = { mapFilterExpanded = false }, - mapFilterState = mapFilterState, - mapViewModel = mapViewModel, - ) - }, - mapTypeContent = { - MapButton( - icon = MeshtasticIcons.Layers, - contentDescription = stringResource(Res.string.map_style_selection), - onClick = { showMapStyleDialog = true }, - ) - }, - isLocationTrackingEnabled = myLocationOverlay != null, - onToggleLocationTracking = { - if (locationPermissionsState.allPermissionsGranted) { - map.toggleMyLocation() - } else { - triggerLocationToggleAfterPermission = true - locationPermissionsState.launchMultiplePermissionRequest() - } - }, - ) - } - } - } - - if (showMapStyleDialog) { - MapStyleDialog( - selectedMapStyle = mapViewModel.mapStyleId, - onDismiss = { showMapStyleDialog = false }, - onSelectMapStyle = { - mapViewModel.mapStyleId = it - map.setTileSource(loadOnlineTileSourceBase()) - }, - ) - } - - if (showCacheManagerDialog) { - CacheManagerDialog( - onClickOption = { option -> - when (option) { - CacheManagerOption.CurrentCacheSize -> { - scope.launch { context.showToast(Res.string.calculating) } - showCurrentCacheInfo = true - } - CacheManagerOption.DownloadRegion -> map.generateBoxOverlay() - - CacheManagerOption.ClearTiles -> showPurgeTileSourceDialog = true - CacheManagerOption.Cancel -> Unit - } - showCacheManagerDialog = false - }, - onDismiss = { showCacheManagerDialog = false }, - ) - } - - if (showCurrentCacheInfo) { - CacheInfoDialog(mapView = map, onDismiss = { showCurrentCacheInfo = false }) - } - - if (showPurgeTileSourceDialog) { - PurgeTileSourceDialog(onDismiss = { showPurgeTileSourceDialog = false }) - } - - if (showEditWaypointDialog != null) { - EditWaypointDialog( - waypoint = showEditWaypointDialog ?: return, // Safe call - onSendClicked = { waypoint -> - Logger.d { "User clicked send waypoint ${waypoint.id}" } - showEditWaypointDialog = null - - val newId = if (waypoint.id == 0) mapViewModel.generatePacketId() else waypoint.id - val newName = if (waypoint.name.isNullOrEmpty()) "Dropped Pin" else waypoint.name - val newExpire = if (waypoint.expire == 0) Int.MAX_VALUE else waypoint.expire - val newLockedTo = if (waypoint.locked_to != 0) mapViewModel.myNodeNum ?: 0 else 0 - val newIcon = if (waypoint.icon == 0) 128205 else waypoint.icon - - mapViewModel.sendWaypoint( - waypoint.copy( - id = newId, - name = newName, - expire = newExpire, - locked_to = newLockedTo, - icon = newIcon, - ), - ) - }, - onDeleteClicked = { waypoint -> - Logger.d { "User clicked delete waypoint ${waypoint.id}" } - showEditWaypointDialog = null - showDeleteMarkerDialog(waypoint) - }, - onDismissRequest = { - Logger.d { "User clicked cancel marker edit dialog" } - showEditWaypointDialog = null - }, - ) - } -} - -/** F-Droid main map filter dropdown — favorites, waypoints, precision circle, and last-heard time filter slider. */ -@Composable -private fun FdroidMainMapFilterDropdown( - expanded: Boolean, - onDismissRequest: () -> Unit, - mapFilterState: MapFilterState, - mapViewModel: MapViewModel, -) { - DropdownMenu( - expanded = expanded, - onDismissRequest = onDismissRequest, - modifier = Modifier.background(MaterialTheme.colorScheme.surface), - ) { - DropdownMenuItem( - text = { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = MeshtasticIcons.Favorite, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text(text = stringResource(Res.string.only_favorites), modifier = Modifier.weight(1f)) - Checkbox( - checked = mapFilterState.onlyFavorites, - onCheckedChange = { mapViewModel.toggleOnlyFavorites() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleOnlyFavorites() }, - ) - DropdownMenuItem( - text = { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = MeshtasticIcons.PinDrop, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text(text = stringResource(Res.string.show_waypoints), modifier = Modifier.weight(1f)) - Checkbox( - checked = mapFilterState.showWaypoints, - onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleShowWaypointsOnMap() }, - ) - DropdownMenuItem( - text = { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = MeshtasticIcons.Lens, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text(text = stringResource(Res.string.show_precision_circle), modifier = Modifier.weight(1f)) - Checkbox( - checked = mapFilterState.showPrecisionCircle, - onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - ) - HorizontalDivider() - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - val filterOptions = LastHeardFilter.entries - val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter) - var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } - Text( - text = - stringResource( - Res.string.last_heard_filter_label, - stringResource(mapFilterState.lastHeardFilter.label), - ), - style = MaterialTheme.typography.labelLarge, - ) - Slider( - value = sliderPosition, - onValueChange = { sliderPosition = it }, - onValueChangeFinished = { - val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1) - mapViewModel.setLastHeardFilter(filterOptions[newIndex]) - }, - valueRange = 0f..(filterOptions.size - 1).toFloat(), - steps = filterOptions.size - 2, - ) - } - } -} - -@Composable -private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) { - val selected = remember { mutableStateOf(selectedMapStyle) } - - MapsDialog(onDismiss = onDismiss) { - CustomTileSource.mTileSources.values.forEachIndexed { index, style -> - ListItem( - text = style, - trailingIcon = if (index == selected.value) MeshtasticIcons.Check else null, - onClick = { - selected.value = index - onSelectMapStyle(index) - onDismiss() - }, - ) - } - } -} - -private enum class CacheManagerOption(val label: StringResource) { - CurrentCacheSize(label = Res.string.map_cache_size), - DownloadRegion(label = Res.string.map_download_region), - ClearTiles(label = Res.string.map_clear_tiles), - Cancel(label = Res.string.cancel), -} - -@Composable -private fun CacheManagerDialog(onClickOption: (CacheManagerOption) -> Unit, onDismiss: () -> Unit) { - MapsDialog(title = stringResource(Res.string.map_offline_manager), onDismiss = onDismiss) { - CacheManagerOption.entries.forEach { option -> - ListItem(text = stringResource(option.label), trailingIcon = null) { - onClickOption(option) - onDismiss() - } - } - } -} - -@Composable -private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) { - val (cacheCapacity, currentCacheUsage) = - remember(mapView) { - val cacheManager = CacheManager(mapView) - cacheManager.cacheCapacity() to cacheManager.currentCacheUsage() - } - - MapsDialog( - title = stringResource(Res.string.map_cache_manager), - onDismiss = onDismiss, - negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } }, - ) { - Text( - modifier = Modifier.padding(16.dp), - text = - stringResource( - Res.string.map_cache_info, - cacheCapacity / (1024.0 * 1024.0), - currentCacheUsage / (1024.0 * 1024.0), - ), - ) - } -} - -@Composable -private fun PurgeTileSourceDialog(onDismiss: () -> Unit) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - val cache = SqlTileWriterExt() - - val sourceList by derivedStateOf { cache.sources.map { it.source as String } } - - val selected = remember { mutableStateListOf() } - - MapsDialog( - title = stringResource(Res.string.map_tile_source), - positiveButton = { - TextButton( - enabled = selected.isNotEmpty(), - onClick = { - selected.forEach { selectedIndex -> - val source = sourceList[selectedIndex] - scope.launch { - context.showToast( - if (cache.purgeCache(source)) { - getString(Res.string.map_purge_success, source) - } else { - getString(Res.string.map_purge_fail) - }, - ) - } - } - - onDismiss() - }, - ) { - Text(text = stringResource(Res.string.clear)) - } - }, - negativeButton = { TextButton(onClick = onDismiss) { Text(text = stringResource(Res.string.cancel)) } }, - onDismiss = onDismiss, - ) { - sourceList.forEachIndexed { index, source -> - val isSelected = selected.contains(index) - BasicListItem( - text = source, - trailingContent = { Checkbox(checked = isSelected, onCheckedChange = {}) }, - onClick = { - if (isSelected) { - selected.remove(index) - } else { - selected.add(index) - } - }, - ) {} - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun MapsDialog( - title: String? = null, - onDismiss: () -> Unit, - positiveButton: (@Composable () -> Unit)? = null, - negativeButton: (@Composable () -> Unit)? = null, - content: @Composable ColumnScope.() -> Unit, -) { - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.wrapContentWidth().wrapContentHeight(), - shape = MaterialTheme.shapes.large, - color = AlertDialogDefaults.containerColor, - tonalElevation = AlertDialogDefaults.TonalElevation, - ) { - Column { - title?.let { - Text( - modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp), - text = it, - style = MaterialTheme.typography.titleLarge, - ) - } - - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { content() } - if (positiveButton != null || negativeButton != null) { - Row(Modifier.align(Alignment.End)) { - positiveButton?.invoke() - negativeButton?.invoke() - } - } - } - } - } -} - -private const val WAYPOINT_ZOOM = 15.0 diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt deleted file mode 100644 index 3cc0dbaf0..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map - -import android.graphics.Color -import android.graphics.DashPathEffect -import android.graphics.Paint -import android.graphics.Typeface -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.content.ContextCompat -import org.meshtastic.app.R -import org.meshtastic.proto.Position -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.CopyrightOverlay -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.Polyline -import org.osmdroid.views.overlay.ScaleBarOverlay -import org.osmdroid.views.overlay.advancedpolyline.MonochromaticPaintList -import org.osmdroid.views.overlay.gridlines.LatLonGridlineOverlay2 - -/** Adds copyright to map depending on what source is showing */ -fun MapView.addCopyright() { - if (overlays.none { it is CopyrightOverlay }) { - val copyrightNotice: String = tileProvider.tileSource.copyrightNotice ?: return - val copyrightOverlay = CopyrightOverlay(context) - copyrightOverlay.setCopyrightNotice(copyrightNotice) - overlays.add(copyrightOverlay) - } -} - -/** - * Create LatLong Grid line overlay - * - * @param enabled: turn on/off gridlines - */ -fun MapView.createLatLongGrid(enabled: Boolean) { - val latLongGridOverlay = LatLonGridlineOverlay2() - latLongGridOverlay.isEnabled = enabled - if (latLongGridOverlay.isEnabled) { - val textPaint = - Paint().apply { - textSize = 40f - color = Color.GRAY - isAntiAlias = true - isFakeBoldText = true - textAlign = Paint.Align.CENTER - } - latLongGridOverlay.textPaint = textPaint - latLongGridOverlay.setBackgroundColor(Color.TRANSPARENT) - latLongGridOverlay.setLineWidth(3.0f) - latLongGridOverlay.setLineColor(Color.GRAY) - overlays.add(latLongGridOverlay) - } -} - -fun MapView.addScaleBarOverlay(density: Density) { - if (overlays.none { it is ScaleBarOverlay }) { - val scaleBarOverlay = - ScaleBarOverlay(this).apply { - setAlignBottom(true) - with(density) { - setScaleBarOffset(15.dp.toPx().toInt(), 40.dp.toPx().toInt()) - setTextSize(12.sp.toPx()) - } - textPaint.apply { - isAntiAlias = true - typeface = Typeface.DEFAULT_BOLD - } - } - overlays.add(scaleBarOverlay) - } -} - -fun MapView.addPolyline(density: Density, geoPoints: List, onClick: () -> Unit): Polyline { - val polyline = - Polyline(this).apply { - val borderPaint = - Paint().apply { - color = Color.BLACK - isAntiAlias = true - strokeWidth = with(density) { 10.dp.toPx() } - style = Paint.Style.STROKE - strokeJoin = Paint.Join.ROUND - strokeCap = Paint.Cap.ROUND - pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f) - } - outlinePaintLists.add(MonochromaticPaintList(borderPaint)) - val fillPaint = - Paint().apply { - color = Color.WHITE - isAntiAlias = true - strokeWidth = with(density) { 6.dp.toPx() } - style = Paint.Style.FILL_AND_STROKE - strokeJoin = Paint.Join.ROUND - strokeCap = Paint.Cap.ROUND - pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f) - } - outlinePaintLists.add(MonochromaticPaintList(fillPaint)) - setPoints(geoPoints) - setOnClickListener { _, _, _ -> - onClick() - true - } - } - overlays.add(polyline) - - return polyline -} - -fun MapView.addPositionMarkers(positions: List, onClick: (Int) -> Unit): List { - val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation) - val markers = - positions.map { pos -> - Marker(this).apply { - icon = navIcon - rotation = ((pos.ground_track ?: 0) * 1e-5).toFloat() - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) - position = GeoPoint((pos.latitude_i ?: 0) * 1e-7, (pos.longitude_i ?: 0) * 1e-7) - setOnMarkerClickListener { _, _ -> - onClick(pos.time) - true - } - } - } - overlays.addAll(markers) - - return markers -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt deleted file mode 100644 index 1ffe68aa1..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map - -import androidx.lifecycle.SavedStateHandle -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.common.BuildConfigProvider -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.repository.MapPrefs -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.feature.map.BaseMapViewModel -import org.meshtastic.proto.LocalConfig - -@Suppress("LongParameterList") -@KoinViewModel -class MapViewModel( - mapPrefs: MapPrefs, - packetRepository: PacketRepository, - nodeRepository: NodeRepository, - radioController: RadioController, - radioConfigRepository: RadioConfigRepository, - buildConfigProvider: BuildConfigProvider, - savedStateHandle: SavedStateHandle, -) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) { - - private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId")) - val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow() - - fun setWaypointId(id: Int?) { - if (_selectedWaypointId.value != id) { - _selectedWaypointId.value = id - } - } - - var mapStyleId: Int - get() = mapPrefs.mapStyle.value - set(value) { - mapPrefs.setMapStyle(value) - } - - val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) - - val config - get() = localConfig.value - - val applicationId = buildConfigProvider.applicationId -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt deleted file mode 100644 index c16d87163..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableDoubleStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import org.osmdroid.config.Configuration -import org.osmdroid.tileprovider.tilesource.ITileSource -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.CustomZoomButtonsController -import org.osmdroid.views.MapView - -private const val MIN_ZOOM_LEVEL = 1.5 -private const val MAX_ZOOM_LEVEL = 20.0 -private const val DEFAULT_ZOOM_LEVEL = 15.0 - -@Suppress("MagicNumber") -@Composable -fun rememberMapViewWithLifecycle( - applicationId: String, - box: BoundingBox, - tileSource: ITileSource = TileSourceFactory.DEFAULT_TILE_SOURCE, -): MapView { - val zoom = - if (box.requiredZoomLevel().isFinite()) { - (box.requiredZoomLevel() - 0.5).coerceAtLeast(MIN_ZOOM_LEVEL) - } else { - DEFAULT_ZOOM_LEVEL - } - val center = GeoPoint(box.centerLatitude, box.centerLongitude) - return rememberMapViewWithLifecycle( - applicationId = applicationId, - zoomLevel = zoom, - mapCenter = center, - tileSource = tileSource, - ) -} - -@Suppress("LongMethod") -@Composable -internal fun rememberMapViewWithLifecycle( - applicationId: String, - zoomLevel: Double = MIN_ZOOM_LEVEL, - mapCenter: GeoPoint = GeoPoint(0.0, 0.0), - tileSource: ITileSource = TileSourceFactory.DEFAULT_TILE_SOURCE, -): MapView { - var savedZoom by rememberSaveable { mutableDoubleStateOf(zoomLevel) } - var savedCenter by - rememberSaveable( - stateSaver = - Saver( - save = { mapOf("latitude" to it.latitude, "longitude" to it.longitude) }, - restore = { GeoPoint(it["latitude"] ?: 0.0, it["longitude"] ?: .0) }, - ), - ) { - mutableStateOf(mapCenter) - } - - val context = LocalContext.current - val mapView = remember { - MapView(context).apply { - clipToOutline = true - - // Required to get online tiles - Configuration.getInstance().userAgentValue = applicationId - setTileSource(tileSource) - isVerticalMapRepetitionEnabled = false // disables map repetition - setMultiTouchControls(true) - val bounds = overlayManager.tilesOverlay.bounds // bounds scrollable map - setScrollableAreaLimitLatitude(bounds.actualNorth, bounds.actualSouth, 0) - // scales the map tiles to the display density of the screen - isTilesScaledToDpi = true - // sets the minimum zoom level (the furthest out you can zoom) - minZoomLevel = MIN_ZOOM_LEVEL - maxZoomLevel = MAX_ZOOM_LEVEL - // Disables default +/- button for zooming - zoomController.setVisibility(CustomZoomButtonsController.Visibility.SHOW_AND_FADEOUT) - - controller.setZoom(savedZoom) - controller.setCenter(savedCenter) - } - } - val lifecycle = LocalLifecycleOwner.current.lifecycle - DisposableEffect(lifecycle) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_PAUSE -> { - mapView.onPause() - } - - Lifecycle.Event.ON_RESUME -> { - mapView.onResume() - } - - Lifecycle.Event.ON_STOP -> { - savedCenter = mapView.projection.currentCenter - savedZoom = mapView.zoomLevelDouble - } - - else -> {} - } - } - - lifecycle.addObserver(observer) - - onDispose { lifecycle.removeObserver(observer) } - } - return mapView -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt deleted file mode 100644 index 112449d1f..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map - -import android.database.Cursor -import org.meshtastic.core.common.util.nowMillis -import org.osmdroid.tileprovider.modules.DatabaseFileArchive -import org.osmdroid.tileprovider.modules.SqlTileWriter - -/** - * Extended the sqlite tile writer to have some additional query functions. A this point it's unclear if there is a need - * to put these with the osmdroid-android library, thus they were put here as more of an example. - * - * created on 12/21/2016. - * - * @author Alex O'Ree - * @since 5.6.2 - */ -class SqlTileWriterExt : SqlTileWriter() { - fun select(rows: Int, offset: Int): Cursor? = this.db?.rawQuery( - "select " + - DatabaseFileArchive.COLUMN_KEY + - "," + - COLUMN_EXPIRES + - "," + - DatabaseFileArchive.COLUMN_PROVIDER + - " from " + - DatabaseFileArchive.TABLE + - " limit ? offset ?", - arrayOf(rows.toString() + "", offset.toString() + ""), - ) - - /** - * gets all the tiles sources that we have tiles for in the cache database and their counts - * - * @return - */ - val sources: List - get() { - val db = db - val ret: MutableList = ArrayList() - if (db == null) { - return ret - } - var cur: Cursor? = null - try { - cur = - db.rawQuery( - "select " + - DatabaseFileArchive.COLUMN_PROVIDER + - ",count(*) " + - ",min(length(" + - DatabaseFileArchive.COLUMN_TILE + - ")) " + - ",max(length(" + - DatabaseFileArchive.COLUMN_TILE + - ")) " + - ",sum(length(" + - DatabaseFileArchive.COLUMN_TILE + - ")) " + - "from " + - DatabaseFileArchive.TABLE + - " " + - "group by " + - DatabaseFileArchive.COLUMN_PROVIDER, - null, - ) - while (cur.moveToNext()) { - val c = SourceCount() - c.source = cur.getString(0) - c.rowCount = cur.getLong(1) - c.sizeMin = cur.getLong(2) - c.sizeMax = cur.getLong(3) - c.sizeTotal = cur.getLong(4) - c.sizeAvg = c.sizeTotal / c.rowCount - ret.add(c) - } - } catch (e: Exception) { - catchException(e) - } finally { - cur?.close() - } - return ret - } - - val rowCountExpired: Long - get() = getRowCount("$COLUMN_EXPIRES. - */ -package org.meshtastic.app.map.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.map_select_download_region -import org.meshtastic.core.resources.map_start_download -import org.meshtastic.core.resources.map_tile_download_estimate - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun CacheLayout( - cacheEstimate: String, - onExecuteJob: () -> Unit, - onCancelDownload: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = - modifier - .fillMaxWidth() - .wrapContentHeight() - .background(color = MaterialTheme.colorScheme.background) - .padding(8.dp), - ) { - Text( - text = stringResource(Res.string.map_select_download_region), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.headlineSmall, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(Res.string.map_tile_download_estimate) + " " + cacheEstimate, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge, - ) - - FlowRow( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(space = 8.dp), - ) { - Button(onClick = onCancelDownload, modifier = Modifier.weight(1f)) { - Text(text = stringResource(Res.string.cancel), color = MaterialTheme.colorScheme.onPrimary) - } - Button(onClick = onExecuteJob, modifier = Modifier.weight(1f)) { - Text(text = stringResource(Res.string.map_start_download), color = MaterialTheme.colorScheme.onPrimary) - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun CacheLayoutPreview() { - CacheLayout(cacheEstimate = "100 tiles", onExecuteJob = {}, onCancelDownload = {}) -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt deleted file mode 100644 index 7568d695a..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.component - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.map_download_region -import org.meshtastic.core.ui.icon.Download -import org.meshtastic.core.ui.icon.MeshtasticIcons - -@Composable -fun DownloadButton(enabled: Boolean, onClick: () -> Unit) { - AnimatedVisibility( - visible = enabled, - enter = - slideInHorizontally( - initialOffsetX = { it }, - animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing), - ), - exit = - slideOutHorizontally( - targetOffsetX = { it }, - animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing), - ), - ) { - FloatingActionButton(onClick = onClick, contentColor = MaterialTheme.colorScheme.primary) { - Icon( - imageVector = MeshtasticIcons.Download, - contentDescription = stringResource(Res.string.map_download_region), - modifier = Modifier.scale(1.25f), - ) - } - } -} - -// @Preview(showBackground = true) -// @Composable -// private fun DownloadButtonPreview() { -// DownloadButton(true, onClick = {}) -// } diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt deleted file mode 100644 index c41798bf0..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt +++ /dev/null @@ -1,357 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.component - -import android.app.DatePickerDialog -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.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -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.graphics.Color -import androidx.compose.ui.platform.LocalContext -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.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.Month -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.common.util.systemTimeZone -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.date -import org.meshtastic.core.resources.delete -import org.meshtastic.core.resources.description -import org.meshtastic.core.resources.expires -import org.meshtastic.core.resources.locked -import org.meshtastic.core.resources.name -import org.meshtastic.core.resources.send -import org.meshtastic.core.resources.time -import org.meshtastic.core.resources.waypoint_edit -import org.meshtastic.core.resources.waypoint_new -import org.meshtastic.core.ui.component.EditTextPreference -import org.meshtastic.core.ui.emoji.EmojiPickerDialog -import org.meshtastic.core.ui.icon.CalendarMonth -import org.meshtastic.core.ui.icon.Lock -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.theme.AppTheme -import org.meshtastic.proto.Waypoint -import kotlin.time.Duration.Companion.hours -import kotlin.time.Instant - -@Suppress("LongMethod", "CyclomaticComplexMethod") -@OptIn(ExperimentalLayoutApi::class) -@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) Res.string.waypoint_new else Res.string.waypoint_edit - - @Suppress("MagicNumber") - val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon - var showEmojiPickerView by remember { mutableStateOf(false) } - - // Get current context for dialogs - val context = LocalContext.current - val tz = systemTimeZone - - // Determine locale-specific date format - val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) } - // Check if 24-hour format is preferred - val is24Hour = remember { android.text.format.DateFormat.is24HourFormat(context) } - val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) } - - val currentInstant = - remember(waypointInput.expire) { - val expire = waypointInput.expire - if (expire != 0 && expire != Int.MAX_VALUE) { - kotlin.time.Instant.fromEpochSeconds(expire.toLong()) - } else { - kotlin.time.Clock.System.now() + 8.hours - } - } - - // State to hold selected date and time - var selectedDate by - remember(currentInstant) { - mutableStateOf( - if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) { - dateFormat.format(java.util.Date(currentInstant.toEpochMilliseconds())) - } else { - "" - }, - ) - } - var selectedTime by - remember(currentInstant) { - mutableStateOf( - if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) { - timeFormat.format(java.util.Date(currentInstant.toEpochMilliseconds())) - } else { - "" - }, - ) - } - - if (!showEmojiPickerView) { - AlertDialog( - onDismissRequest = onDismissRequest, - shape = RoundedCornerShape(16.dp), - text = { - Column(modifier = modifier.fillMaxWidth()) { - Text( - text = stringResource(title), - style = - MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - ), - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - ) - EditTextPreference( - title = stringResource(Res.string.name), - value = waypointInput.name, - maxSize = 29, - enabled = true, - isError = false, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = {}), - onValueChanged = { waypointInput = waypointInput.copy(name = it) }, - trailingIcon = { - IconButton(onClick = { showEmojiPickerView = true }) { - Text( - text = String(Character.toChars(emoji)), - modifier = - Modifier.background(MaterialTheme.colorScheme.background, CircleShape) - .padding(4.dp), - fontSize = 24.sp, - color = Color.Unspecified.copy(alpha = 1f), - ) - } - }, - ) - EditTextPreference( - title = stringResource(Res.string.description), - value = waypointInput.description, - maxSize = 99, - enabled = true, - isError = false, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = {}), - onValueChanged = { waypointInput = waypointInput.copy(description = it) }, - ) - Row( - modifier = Modifier.fillMaxWidth().size(48.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Image( - imageVector = MeshtasticIcons.Lock, - contentDescription = stringResource(Res.string.locked), - ) - Text(stringResource(Res.string.locked)) - Switch( - modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End), - checked = waypointInput.locked_to != 0, - onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) }, - ) - } - - val ldt = currentInstant.toLocalDateTime(tz) - val datePickerDialog = - DatePickerDialog( - context, - { _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int -> - val newLdt = - LocalDateTime( - year = selectedYear, - month = Month(selectedMonth + 1), - day = selectedDay, - hour = ldt.hour, - minute = ldt.minute, - second = ldt.second, - nanosecond = ldt.nanosecond, - ) - waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt()) - }, - ldt.year, - ldt.month.ordinal, - ldt.day, - ) - - val timePickerDialog = - android.app.TimePickerDialog( - context, - { _: TimePicker, selectedHour: Int, selectedMinute: Int -> - val newLdt = - LocalDateTime( - year = ldt.year, - month = ldt.month, - day = ldt.day, - hour = selectedHour, - minute = selectedMinute, - second = ldt.second, - nanosecond = ldt.nanosecond, - ) - waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt()) - }, - ldt.hour, - ldt.minute, - is24Hour, - ) - - Row( - modifier = Modifier.fillMaxWidth().size(48.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Image( - imageVector = MeshtasticIcons.CalendarMonth, - contentDescription = stringResource(Res.string.expires), - ) - Text(stringResource(Res.string.expires)) - Switch( - modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End), - checked = waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0, - onCheckedChange = { isChecked -> - if (isChecked) { - waypointInput = waypointInput.copy(expire = currentInstant.epochSeconds.toInt()) - } else { - waypointInput = waypointInput.copy(expire = Int.MAX_VALUE) - } - }, - ) - } - - if (waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = { datePickerDialog.show() }) { Text(stringResource(Res.string.date)) } - Text( - modifier = Modifier.padding(top = 4.dp), - text = selectedDate, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - ) - } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = { timePickerDialog.show() }) { Text(stringResource(Res.string.time)) } - Text( - modifier = Modifier.padding(top = 4.dp), - text = selectedTime, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - ) - } - } - } - } - }, - confirmButton = { - FlowRow( - modifier = modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.Center, - ) { - TextButton(modifier = modifier.weight(1f), onClick = onDismissRequest) { - Text(stringResource(Res.string.cancel)) - } - if (waypoint.id != 0) { - Button( - modifier = modifier.weight(1f), - onClick = { onDeleteClicked(waypointInput) }, - enabled = !(waypointInput.name.isNullOrEmpty()), - ) { - Text(stringResource(Res.string.delete)) - } - } - Button(modifier = modifier.weight(1f), onClick = { onSendClicked(waypointInput) }, enabled = true) { - Text(stringResource(Res.string.send)) - } - } - }, - ) - } else { - EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) { - showEmojiPickerView = false - waypointInput = waypointInput.copy(icon = it.codePointAt(0)) - } - } -} - -@Preview(showBackground = true) -@Composable -@Suppress("MagicNumber") -private fun EditWaypointFormPreview() { - AppTheme { - EditWaypointDialog( - waypoint = - Waypoint( - id = 123, - name = "Test 123", - description = "This is only a test", - icon = 128169, - expire = (nowSeconds.toInt() + 8 * 3600), - ), - onSendClicked = {}, - onDeleteClicked = {}, - onDismissRequest = {}, - ) - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt deleted file mode 100644 index de0f8c6c2..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.model - -import org.osmdroid.tileprovider.tilesource.ITileSource -import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.tileprovider.tilesource.TileSourcePolicy -import org.osmdroid.util.MapTileIndex - -@Suppress("UnusedPrivateProperty") -class CustomTileSource { - - companion object { - val OPENWEATHER_RADAR = - OnlineTileSourceAuth( - "Open Weather Map", - 1, - 22, - 256, - ".png", - arrayOf("https://tile.openweathermap.org/map/"), - "Openweathermap", - TileSourcePolicy( - 4, - TileSourcePolicy.FLAG_NO_BULK or - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - "precipitation", - "", - ) - private val ESRI_IMAGERY = - object : - OnlineTileSourceBase( - "ESRI World Overview", - 1, - 20, - 256, - ".jpg", - arrayOf("https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/"), - "Esri, Maxar, Earthstar Geographics, and the GIS User Community", - TileSourcePolicy( - 4, - TileSourcePolicy.FLAG_NO_BULK or - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - ) { - override fun getTileURLString(pMapTileIndex: Long): String = baseUrl + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex) + - "/" + - MapTileIndex.getX(pMapTileIndex) + - mImageFilenameEnding - ) - } - - private val ESRI_WORLD_TOPO = - object : - OnlineTileSourceBase( - "ESRI World TOPO", - 1, - 20, - 256, - ".jpg", - arrayOf("https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/"), - "Esri, HERE, Garmin, FAO, NOAA, USGS, © OpenStreetMap contributors, and the GIS User Community ", - TileSourcePolicy( - 4, - TileSourcePolicy.FLAG_NO_BULK or - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - ) { - override fun getTileURLString(pMapTileIndex: Long): String = baseUrl + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex) + - "/" + - MapTileIndex.getX(pMapTileIndex) + - mImageFilenameEnding - ) - } - private val USGS_HYDRO_CACHE = - object : - OnlineTileSourceBase( - "USGS Hydro Cache", - 0, - 18, - 256, - "", - arrayOf("https://basemap.nationalmap.gov/arcgis/rest/services/USGSHydroCached/MapServer/tile/"), - "USGS", - TileSourcePolicy( - 2, - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - ) { - override fun getTileURLString(pMapTileIndex: Long): String = baseUrl + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex) + - "/" + - MapTileIndex.getX(pMapTileIndex) + - mImageFilenameEnding - ) - } - private val USGS_SHADED_RELIEF = - object : - OnlineTileSourceBase( - "USGS Shaded Relief Only", - 0, - 18, - 256, - "", - arrayOf( - "https://basemap.nationalmap.gov/arcgis/rest/services/USGSShadedReliefOnly/MapServer/tile/", - ), - "USGS", - TileSourcePolicy( - 2, - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - ) { - override fun getTileURLString(pMapTileIndex: Long): String = baseUrl + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex) + - "/" + - MapTileIndex.getX(pMapTileIndex) + - mImageFilenameEnding - ) - } - - /** WMS TILE SERVER More research is required to get this to function correctly with overlays */ - val NOAA_RADAR_WMS = - NOAAWmsTileSource( - "Recent Weather Radar", - arrayOf( - "https://new.nowcoast.noaa.gov/arcgis/services/nowcoast/" + - "radar_meteo_imagery_nexrad_time/MapServer/WmsServer?", - ), - "1", - "1.1.0", - "", - "EPSG%3A3857", - "", - "image/png", - ) - - /** =============================================================================================== */ - private val MAPNIK: OnlineTileSourceBase = TileSourceFactory.MAPNIK - private val USGS_TOPO: OnlineTileSourceBase = TileSourceFactory.USGS_TOPO - private val OPEN_TOPO: OnlineTileSourceBase = TileSourceFactory.OpenTopo - private val USGS_SAT: OnlineTileSourceBase = TileSourceFactory.USGS_SAT - private val SEAMAP: OnlineTileSourceBase = TileSourceFactory.OPEN_SEAMAP - val DEFAULT_TILE_SOURCE: OnlineTileSourceBase = TileSourceFactory.DEFAULT_TILE_SOURCE - - /** Source for each available [ITileSource] and their display names. */ - val mTileSources: Map = - mapOf( - MAPNIK to "OpenStreetMap", - USGS_TOPO to "USGS TOPO", - OPEN_TOPO to "Open TOPO", - ESRI_WORLD_TOPO to "ESRI World TOPO", - USGS_SAT to "USGS Satellite", - ESRI_IMAGERY to "ESRI World Overview", - ) - - fun getTileSource(index: Int): ITileSource = mTileSources.keys.elementAtOrNull(index) ?: DEFAULT_TILE_SOURCE - - fun getTileSource(aName: String): ITileSource { - for (tileSource: ITileSource in mTileSources.keys) { - if (tileSource.name().equals(aName)) { - return tileSource - } - } - throw IllegalArgumentException("No such tile source: $aName") - } - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt deleted file mode 100644 index da94a7725..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.model - -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.RectF -import android.view.MotionEvent -import org.meshtastic.app.map.dpToPx -import org.meshtastic.app.map.spToPx -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.Polygon - -class MarkerWithLabel(mapView: MapView?, label: String, emoji: String? = null) : Marker(mapView) { - - companion object { - private const val LABEL_CORNER_RADIUS_DP = 4f - private const val LABEL_Y_OFFSET_DP = 34f - private const val FONT_SIZE_SP = 14f - private const val EMOJI_FONT_SIZE_SP = 20f - } - - private val labelYOffsetPx by lazy { mapView?.context?.dpToPx(LABEL_Y_OFFSET_DP) ?: 100 } - - private val labelCornerRadiusPx by lazy { mapView?.context?.dpToPx(LABEL_CORNER_RADIUS_DP) ?: 12 } - - private var nodeColor: Int = Color.GRAY - - fun setNodeColors(colors: Pair) { - nodeColor = colors.second - } - - private var precisionBits: Int? = null - - fun setPrecisionBits(bits: Int) { - precisionBits = bits - } - - @Suppress("MagicNumber") - private fun getPrecisionMeters(): Double? = when (precisionBits) { - 10 -> 23345.484932 - 11 -> 11672.7369 - 12 -> 5836.36288 - 13 -> 2918.175876 - 14 -> 1459.0823719999053 - 15 -> 729.53562 - 16 -> 364.7622 - 17 -> 182.375556 - 18 -> 91.182212 - 19 -> 45.58554 - else -> null - } - - private var onLongClickListener: (() -> Boolean)? = null - - fun setOnLongClickListener(listener: () -> Boolean) { - onLongClickListener = listener - } - - private val mLabel = label - private val mEmoji = emoji - private val textPaint = - Paint().apply { - textSize = mapView?.context?.spToPx(FONT_SIZE_SP)?.toFloat() ?: 40f - color = Color.DKGRAY - isAntiAlias = true - isFakeBoldText = true - textAlign = Paint.Align.CENTER - } - private val emojiPaint = - Paint().apply { - textSize = mapView?.context?.spToPx(EMOJI_FONT_SIZE_SP)?.toFloat() ?: 80f - isAntiAlias = true - textAlign = Paint.Align.CENTER - } - - private val bgPaint = Paint().apply { color = Color.WHITE } - - private fun getTextBackgroundSize(text: String, x: Float, y: Float): RectF { - val fontMetrics = textPaint.fontMetrics - val halfTextLength = textPaint.measureText(text) / 2 + 3 - return RectF((x - halfTextLength), (y + fontMetrics.top), (x + halfTextLength), (y + fontMetrics.bottom)) - } - - override fun onLongPress(event: MotionEvent?, mapView: MapView?): Boolean { - val touched = hitTest(event, mapView) - if (touched && this.id != null) { - return onLongClickListener?.invoke() ?: super.onLongPress(event, mapView) - } - return super.onLongPress(event, mapView) - } - - @Suppress("MagicNumber") - override fun draw(c: Canvas, osmv: MapView?, shadow: Boolean) { - super.draw(c, osmv, false) - val p = mPositionPixels - val bgRect = getTextBackgroundSize(mLabel, p.x.toFloat(), (p.y - labelYOffsetPx.toFloat())) - bgRect.inset(-8F, -2F) - - if (mLabel.isNotEmpty()) { - c.drawRoundRect(bgRect, labelCornerRadiusPx.toFloat(), labelCornerRadiusPx.toFloat(), bgPaint) - c.drawText(mLabel, (p.x - 0F), (p.y - labelYOffsetPx.toFloat()), textPaint) - } - mEmoji?.let { c.drawText(it, (p.x - 0f), (p.y - 30f), emojiPaint) } - - getPrecisionMeters()?.let { radius -> - val polygon = - Polygon(osmv).apply { - points = Polygon.pointsAsCircle(position, radius) - fillPaint.apply { - color = nodeColor - alpha = 48 - } - outlinePaint.apply { - color = nodeColor - alpha = 64 - } - } - polygon.draw(c, osmv, false) - } - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt deleted file mode 100644 index ac438397a..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.model - -import android.content.res.Resources -import co.touchlab.kermit.Logger -import org.osmdroid.api.IMapView -import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase -import org.osmdroid.tileprovider.tilesource.TileSourcePolicy -import org.osmdroid.util.MapTileIndex -import kotlin.math.atan -import kotlin.math.pow -import kotlin.math.sinh - -open class NOAAWmsTileSource( - aName: String, - aBaseUrl: Array, - layername: String, - version: String, - time: String?, - srs: String, - style: String?, - format: String, -) : OnlineTileSourceBase( - aName, - 0, - 5, - 256, - "png", - aBaseUrl, - "", - TileSourcePolicy( - 2, - TileSourcePolicy.FLAG_NO_BULK or - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), -) { - - // array indexes for array to hold bounding boxes. - private val minX = 0 - private val maxX = 1 - private val minY = 2 - private val maxY = 3 - - // Web Mercator n/w corner of the map. - private val tileOrigin = doubleArrayOf(-20037508.34789244, 20037508.34789244) - - // array indexes for that data - private val origX = 0 - private val origY = 1 // " - - // Size of square world map in meters, using WebMerc projection. - private val mapSize = 20037508.34789244 * 2 - private var layer = "" - private var version = "1.1.0" - private var srs = "EPSG%3A3857" // used by geo server - private var format = "" - private var time = "" - private var style: String? = null - private var forceHttps = false - private var forceHttp = false - - init { - Logger.withTag(IMapView.LOGTAG).i { "WMS support is BETA. Please report any issues" } - layer = layername - this.version = version - this.srs = srs - this.style = style - this.format = format - if (time != null) this.time = time - } - - private fun tile2lon(x: Int, z: Int): Double = x / 2.0.pow(z.toDouble()) * 360.0 - 180 - - private fun tile2lat(y: Int, z: Int): Double { - val n = Math.PI - 2.0 * Math.PI * y / 2.0.pow(z.toDouble()) - return Math.toDegrees(atan(sinh(n))) - } - - // Return a web Mercator bounding box given tile x/y indexes and a zoom - // level. - private fun getBoundingBox(x: Int, y: Int, zoom: Int): DoubleArray { - val tileSize = mapSize / 2.0.pow(zoom.toDouble()) - val minx = tileOrigin[origX] + x * tileSize - val maxx = tileOrigin[origX] + (x + 1) * tileSize - val miny = tileOrigin[origY] - (y + 1) * tileSize - val maxy = tileOrigin[origY] - y * tileSize - val bbox = DoubleArray(4) - bbox[minX] = minx - bbox[minY] = miny - bbox[maxX] = maxx - bbox[maxY] = maxy - return bbox - } - - fun isForceHttps(): Boolean = forceHttps - - fun setForceHttps(forceHttps: Boolean) { - this.forceHttps = forceHttps - } - - fun isForceHttp(): Boolean = forceHttp - - fun setForceHttp(forceHttp: Boolean) { - this.forceHttp = forceHttp - } - - override fun getTileURLString(pMapTileIndex: Long): String? { - var baseUrl = baseUrl - if (forceHttps) baseUrl = baseUrl.replace("http://", "https://") - if (forceHttp) baseUrl = baseUrl.replace("https://", "http://") - val sb = StringBuilder(baseUrl) - if (!baseUrl.endsWith("&")) sb.append("service=WMS") - sb.append("&request=GetMap") - sb.append("&version=").append(version) - sb.append("&layers=").append(layer) - if (style != null) sb.append("&styles=").append(style) - sb.append("&format=").append(format) - sb.append("&transparent=true") - sb.append("&height=").append(Resources.getSystem().displayMetrics.heightPixels) - sb.append("&width=").append(Resources.getSystem().displayMetrics.widthPixels) - sb.append("&srs=").append(srs) - sb.append("&size=").append(getSize()) - sb.append("&bbox=") - val bbox = - getBoundingBox( - MapTileIndex.getX(pMapTileIndex), - MapTileIndex.getY(pMapTileIndex), - MapTileIndex.getZoom(pMapTileIndex), - ) - sb.append(bbox[minX]).append(",") - sb.append(bbox[minY]).append(",") - sb.append(bbox[maxX]).append(",") - sb.append(bbox[maxY]) - Logger.withTag(IMapView.LOGTAG).i { sb.toString() } - return sb.toString() - } - - private fun getSize(): String { - val height = Resources.getSystem().displayMetrics.heightPixels - val width = Resources.getSystem().displayMetrics.widthPixels - return "$width,$height" - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt deleted file mode 100644 index 3d51133bd..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.model - -import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase -import org.osmdroid.tileprovider.tilesource.TileSourcePolicy -import org.osmdroid.util.MapTileIndex - -@Suppress("LongParameterList") -open class OnlineTileSourceAuth( - name: String, - zoomLevel: Int, - zoomMaxLevel: Int, - tileSizePixels: Int, - imageFileNameEnding: String, - baseUrl: Array, - pCopyright: String, - tileSourcePolicy: TileSourcePolicy, - layerName: String?, - apiKey: String, -) : OnlineTileSourceBase( - name, - zoomLevel, - zoomMaxLevel, - tileSizePixels, - imageFileNameEnding, - baseUrl, - pCopyright, - tileSourcePolicy, -) { - private var layerName = "" - private var apiKey = "" - - init { - if (layerName != null) { - this.layerName = layerName - } - this.apiKey = apiKey - } - - override fun getTileURLString(pMapTileIndex: Long): String = "$baseUrl$layerName/" + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getX(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex).toString() - ) + - mImageFilenameEnding + - "?appId=$apiKey" -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt deleted file mode 100644 index 77b595d88..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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.app.map.node - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.feature.map.node.NodeMapViewModel -import org.meshtastic.proto.Position - -/** - * Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to obtain - * [NodeMapViewModel.applicationId] and [NodeMapViewModel.mapStyleId], then delegates to the OSMDroid implementation - * ([NodeTrackOsmMap]). - * - * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. - */ -@Composable -fun NodeTrackMap( - destNum: Int, - positions: List, - modifier: Modifier = Modifier, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, -) { - val vm = koinViewModel() - vm.setDestNum(destNum) - NodeTrackOsmMap( - positions = positions, - applicationId = vm.applicationId, - mapStyleId = vm.mapStyleId, - modifier = modifier, - selectedPositionTime = selectedPositionTime, - onPositionSelected = onPositionSelected, - ) -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt deleted file mode 100644 index a6aec4c2d..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt +++ /dev/null @@ -1,162 +0,0 @@ -/* - * 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.app.map.node - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.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.LocalDensity -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.app.map.MapViewModel -import org.meshtastic.app.map.addCopyright -import org.meshtastic.app.map.addPolyline -import org.meshtastic.app.map.addPositionMarkers -import org.meshtastic.app.map.addScaleBarOverlay -import org.meshtastic.app.map.model.CustomTileSource -import org.meshtastic.app.map.rememberMapViewWithLifecycle -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.util.GeoConstants.DEG_D -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.last_heard_filter_label -import org.meshtastic.feature.map.LastHeardFilter -import org.meshtastic.feature.map.component.MapControlsOverlay -import org.meshtastic.proto.Position -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import kotlin.math.roundToInt - -/** - * A focused OSMDroid map composable that renders **only** a node's position track — a dashed polyline with directional - * markers for each historical position. - * - * Applies the [lastHeardTrackFilter][org.meshtastic.feature.map.BaseMapViewModel.MapFilterState.lastHeardTrackFilter] - * from [MapViewModel] to filter positions by time, matching the behavior of the Google Maps implementation. Includes a - * minimal [MapControlsOverlay][org.meshtastic.feature.map.component.MapControlsOverlay] with a track time filter slider - * so users can adjust the time range directly from the map. - * - * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. - * - * Unlike the main [org.meshtastic.app.map.MapView], this composable does **not** include node clusters, waypoints, or - * location tracking. It is designed to be embedded inside the position-log adaptive layout. - */ -@Composable -fun NodeTrackOsmMap( - positions: List, - applicationId: String, - mapStyleId: Int, - modifier: Modifier = Modifier, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, - mapViewModel: MapViewModel = koinViewModel(), -) { - val density = LocalDensity.current - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter - - val filteredPositions = - remember(positions, lastHeardTrackFilter) { - positions.filter { - lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds - } - } - - val geoPoints = - remember(filteredPositions) { - filteredPositions.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) } - } - val cameraView = remember(geoPoints) { BoundingBox.fromGeoPoints(geoPoints) } - val mapView = - rememberMapViewWithLifecycle( - applicationId = applicationId, - box = cameraView, - tileSource = CustomTileSource.getTileSource(mapStyleId), - ) - - var filterMenuExpanded by remember { mutableStateOf(false) } - - Box(modifier = modifier) { - AndroidView( - modifier = Modifier.matchParentSize(), - factory = { mapView }, - update = { map -> - map.overlays.clear() - map.addCopyright() - map.addScaleBarOverlay(density) - map.addPolyline(density, geoPoints) {} - map.addPositionMarkers(filteredPositions) { time -> onPositionSelected?.invoke(time) } - // Center on selected position - if (selectedPositionTime != null) { - val selected = filteredPositions.find { it.time == selectedPositionTime } - if (selected != null) { - val point = GeoPoint((selected.latitude_i ?: 0) * DEG_D, (selected.longitude_i ?: 0) * DEG_D) - map.controller.animateTo(point) - } - } - }, - ) - - // Track filter controls overlay - MapControlsOverlay( - modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), - onToggleFilterMenu = { filterMenuExpanded = true }, - filterDropdownContent = { - DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - val filterOptions = LastHeardFilter.entries - val selectedIndex = filterOptions.indexOf(lastHeardTrackFilter) - var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } - - Text( - text = - stringResource( - Res.string.last_heard_filter_label, - stringResource(lastHeardTrackFilter.label), - ), - style = MaterialTheme.typography.labelLarge, - ) - Slider( - value = sliderPosition, - onValueChange = { sliderPosition = it }, - onValueChangeFinished = { - val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1) - mapViewModel.setLastHeardTrackFilter(filterOptions[newIndex]) - }, - valueRange = 0f..(filterOptions.size - 1).toFloat(), - steps = filterOptions.size - 2, - ) - } - } - }, - ) - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt deleted file mode 100644 index fcf1d47e9..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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.app.map.traceroute - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import org.meshtastic.core.model.TracerouteOverlay -import org.meshtastic.proto.Position - -/** - * Flavor-unified entry point for the embeddable traceroute map. Delegates to the OSMDroid implementation - * ([TracerouteOsmMap]). - */ -@Composable -fun TracerouteMap( - tracerouteOverlay: TracerouteOverlay?, - tracerouteNodePositions: Map, - onMappableCountChanged: (shown: Int, total: Int) -> Unit, - modifier: Modifier = Modifier, -) { - TracerouteOsmMap( - tracerouteOverlay = tracerouteOverlay, - tracerouteNodePositions = tracerouteNodePositions, - onMappableCountChanged = onMappableCountChanged, - modifier = modifier, - ) -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt deleted file mode 100644 index 55b49154a..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt +++ /dev/null @@ -1,288 +0,0 @@ -/* - * 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 . - */ -@file:Suppress("MagicNumber") - -package org.meshtastic.app.map.traceroute - -import android.graphics.Paint -import androidx.appcompat.content.res.AppCompatResources -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.Modifier -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.app.R -import org.meshtastic.app.map.MapViewModel -import org.meshtastic.app.map.addCopyright -import org.meshtastic.app.map.addScaleBarOverlay -import org.meshtastic.app.map.model.CustomTileSource -import org.meshtastic.app.map.model.MarkerWithLabel -import org.meshtastic.app.map.rememberMapViewWithLifecycle -import org.meshtastic.app.map.zoomIn -import org.meshtastic.core.model.TracerouteOverlay -import org.meshtastic.core.model.util.GeoConstants.EARTH_RADIUS_METERS -import org.meshtastic.core.ui.theme.TracerouteColors -import org.meshtastic.core.ui.util.formatAgo -import org.meshtastic.feature.map.tracerouteNodeSelection -import org.meshtastic.proto.Position -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.Polyline -import kotlin.math.PI -import kotlin.math.abs -import kotlin.math.asin -import kotlin.math.atan2 -import kotlin.math.cos -import kotlin.math.sin - -private const val TRACEROUTE_OFFSET_METERS = 100.0 -private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0 -private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5 - -/** - * A focused OSMDroid map composable that renders **only** traceroute visualization — node markers for each hop and - * forward/return offset polylines with auto-centering camera. - * - * Unlike the main `MapView`, this composable does **not** include node clusters, waypoints, location tracking, or any - * map controls. It is designed to be embedded inside `TracerouteMapScreen`'s scaffold. - */ -@Composable -fun TracerouteOsmMap( - tracerouteOverlay: TracerouteOverlay?, - tracerouteNodePositions: Map, - onMappableCountChanged: (shown: Int, total: Int) -> Unit, - modifier: Modifier = Modifier, - mapViewModel: MapViewModel = koinViewModel(), -) { - val context = LocalContext.current - val density = LocalDensity.current - val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() - val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) } - - // Resolve which nodes to display for the traceroute - val tracerouteSelection = - remember(tracerouteOverlay, tracerouteNodePositions, nodes) { - mapViewModel.tracerouteNodeSelection( - tracerouteOverlay = tracerouteOverlay, - tracerouteNodePositions = tracerouteNodePositions, - nodes = nodes, - ) - } - val displayNodes = tracerouteSelection.nodesForMarkers - val nodeLookup = tracerouteSelection.nodeLookup - - // Report mappable count - LaunchedEffect(tracerouteOverlay, displayNodes) { - if (tracerouteOverlay != null) { - onMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size) - } - } - - // Compute polyline GeoPoints from node positions - val forwardPoints = - remember(tracerouteOverlay, nodeLookup) { - tracerouteOverlay?.forwardRoute?.mapNotNull { - nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } - } ?: emptyList() - } - val returnPoints = - remember(tracerouteOverlay, nodeLookup) { - tracerouteOverlay?.returnRoute?.mapNotNull { - nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } - } ?: emptyList() - } - - // Compute offset polylines for visual separation - val headingReferencePoints = - remember(forwardPoints, returnPoints) { - when { - forwardPoints.size >= 2 -> forwardPoints - returnPoints.size >= 2 -> returnPoints - else -> emptyList() - } - } - val forwardOffsetPoints = - remember(forwardPoints, headingReferencePoints) { - offsetPolyline( - points = forwardPoints, - offsetMeters = TRACEROUTE_OFFSET_METERS, - headingReferencePoints = headingReferencePoints, - sideMultiplier = 1.0, - ) - } - val returnOffsetPoints = - remember(returnPoints, headingReferencePoints) { - offsetPolyline( - points = returnPoints, - offsetMeters = TRACEROUTE_OFFSET_METERS, - headingReferencePoints = headingReferencePoints, - sideMultiplier = -1.0, - ) - } - - // Camera auto-center - var hasCentered by remember(tracerouteOverlay) { mutableStateOf(false) } - - // Build initial camera from all traceroute points - val allPoints = remember(forwardPoints, returnPoints) { (forwardPoints + returnPoints).distinct() } - val initialCameraView = - remember(allPoints) { if (allPoints.isEmpty()) null else BoundingBox.fromGeoPoints(allPoints) } - - val mapView = - rememberMapViewWithLifecycle( - applicationId = mapViewModel.applicationId, - box = initialCameraView ?: BoundingBox(), - tileSource = CustomTileSource.getTileSource(mapViewModel.mapStyleId), - ) - - // Center camera on traceroute bounds - LaunchedEffect(tracerouteOverlay, forwardPoints, returnPoints) { - if (tracerouteOverlay == null || hasCentered) return@LaunchedEffect - if (allPoints.isNotEmpty()) { - if (allPoints.size == 1) { - mapView.controller.setCenter(allPoints.first()) - mapView.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM) - } else { - mapView.zoomToBoundingBox( - BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS), - true, - ) - } - hasCentered = true - } - } - - AndroidView( - modifier = modifier, - factory = { mapView.apply { setDestroyMode(false) } }, - update = { map -> - map.overlays.clear() - map.addCopyright() - map.addScaleBarOverlay(density) - - // Render traceroute polylines - buildTraceroutePolylines(forwardOffsetPoints, returnOffsetPoints, density).forEach { map.overlays.add(it) } - - // Render simple node markers - displayNodes.forEach { node -> - val position = GeoPoint(node.latitude, node.longitude) - val marker = - MarkerWithLabel(mapView = map, label = "${node.user.short_name} ${formatAgo(node.position.time)}") - .apply { - id = node.user.id - title = node.user.long_name - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - this.position = position - icon = markerIcon - setNodeColors(node.colors) - } - map.overlays.add(marker) - } - - map.invalidate() - }, - ) -} - -private fun buildTraceroutePolylines( - forwardPoints: List, - returnPoints: List, - density: androidx.compose.ui.unit.Density, -): List { - val polylines = mutableListOf() - - fun buildPolyline(points: List, color: Int, strokeWidth: Float): Polyline = Polyline().apply { - setPoints(points) - outlinePaint.apply { - this.color = color - this.strokeWidth = strokeWidth - strokeCap = Paint.Cap.ROUND - strokeJoin = Paint.Join.ROUND - style = Paint.Style.STROKE - } - } - - forwardPoints - .takeIf { it.size >= 2 } - ?.let { points -> - polylines.add(buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() })) - } - returnPoints - .takeIf { it.size >= 2 } - ?.let { points -> - polylines.add(buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() })) - } - return polylines -} - -// --- Haversine offset math for OSMDroid (no SphericalUtil available) --- - -private fun Double.toRad(): Double = this * PI / 180.0 - -private fun bearingRad(from: GeoPoint, to: GeoPoint): Double { - val lat1 = from.latitude.toRad() - val lat2 = to.latitude.toRad() - val dLon = (to.longitude - from.longitude).toRad() - return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)) -} - -private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint { - val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS - val lat1 = latitude.toRad() - val lon1 = longitude.toRad() - val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad)) - val lon2 = - lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2)) - return GeoPoint(lat2 * 180.0 / PI, lon2 * 180.0 / PI) -} - -private fun offsetPolyline( - points: List, - offsetMeters: Double, - headingReferencePoints: List = points, - sideMultiplier: Double = 1.0, -): List { - val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points - if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points - - val headings = - headingPoints.mapIndexed { index, _ -> - when (index) { - 0 -> bearingRad(headingPoints[0], headingPoints[1]) - headingPoints.lastIndex -> - bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex]) - - else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1]) - } - } - - return points.mapIndexed { index, point -> - val heading = headings[index.coerceIn(0, headings.lastIndex)] - val perpendicularHeading = heading + (PI / 2 * sideMultiplier) - point.offsetPoint(perpendicularHeading, abs(offsetMeters)) - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt deleted file mode 100644 index 447765522..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (c) 2025-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.app.node.component - -import android.view.ViewGroup -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.AndroidView -import org.meshtastic.core.model.Node -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.Marker - -@Composable -fun InlineMap(node: Node, modifier: Modifier = Modifier) { - val context = androidx.compose.ui.platform.LocalContext.current - - val map = remember { - MapView(context).apply { - layoutParams = - ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - - // Default osmdroid tile source. - setTileSource(TileSourceFactory.MAPNIK) - setMultiTouchControls(false) - - controller.setZoom(15.0) - } - } - - LaunchedEffect(node.num) { - val point = GeoPoint(node.latitude, node.longitude) - - map.overlays.clear() - - val marker = - Marker(map).apply { - position = point - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - } - map.overlays.add(marker) - - map.controller.animateTo(point) - } - - AndroidView(factory = { map }, modifier = modifier) -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt b/app/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt deleted file mode 100644 index d6515eeb7..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025-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.app.node.metrics - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.ui.Alignment -import androidx.compose.ui.unit.dp -import org.meshtastic.core.ui.util.TracerouteMapOverlayInsets - -fun getTracerouteMapOverlayInsets(): TracerouteMapOverlayInsets = TracerouteMapOverlayInsets( - overlayAlignment = Alignment.BottomEnd, - overlayPadding = PaddingValues(end = 16.dp, bottom = 16.dp), - contentHorizontalAlignment = Alignment.End, -) diff --git a/app/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt b/app/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt index 802f3b150..479dbca1e 100644 --- a/app/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt +++ b/app/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt @@ -17,7 +17,6 @@ package org.meshtastic.app.di import org.koin.core.annotation.Module -import org.meshtastic.app.map.prefs.di.GoogleMapsKoinModule -@Module(includes = [GoogleNetworkModule::class, GoogleMapsKoinModule::class]) +@Module(includes = [GoogleNetworkModule::class]) class FlavorModule diff --git a/app/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt b/app/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt deleted file mode 100644 index 8a441fa70..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * 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.app.map - -import org.meshtastic.core.ui.util.MapViewProvider - -fun getMapViewProvider(): MapViewProvider = GoogleMapViewProvider() diff --git a/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt b/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt deleted file mode 100644 index 940c4ab5a..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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.app.map - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.annotation.Single -import org.meshtastic.core.ui.util.MapViewProvider - -/** Google Maps implementation of [MapViewProvider]. */ -@Single -class GoogleMapViewProvider : MapViewProvider { - @Composable - override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { - val mapViewModel: MapViewModel = koinViewModel() - LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) } - org.meshtastic.app.map.MapView( - modifier = modifier, - mapViewModel = mapViewModel, - navigateToNodeDetails = navigateToNodeDetails, - ) - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt b/app/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt deleted file mode 100644 index 1aa4a7bab..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright (c) 2025-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.app.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 co.touchlab.kermit.Logger -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) { - Logger.d { "Location settings changed by user." } - // User has enabled location services or improved accuracy. - onPermissionResult(true) // Settings are now adequate, and permission was already granted. - } else { - Logger.d { "Location settings change cancelled by user." } - // User chose not to change settings. The permission itself is still granted, - // but the experience might be degraded. For the purpose of enabling map features, - // we consider this as success if the core permission is there. - // If stricter handling is needed (e.g., block feature if settings not optimal), - // this logic might change. - onPermissionResult(localHasPermission) - } - } - - LaunchedEffect(Unit) { - // Initial permission check - when (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) { - PackageManager.PERMISSION_GRANTED -> { - if (!localHasPermission) { - localHasPermission = true - } - // If permission is already granted, proceed to check location settings. - // The LaunchedEffect(localHasPermission) will handle this. - // No need to call onPermissionResult(true) here yet, let settings check complete. - } - - else -> { - // Request permission if not granted. The launcher's callback will update localHasPermission. - requestLocationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) - } - } - } - - LaunchedEffect(localHasPermission) { - // Handles logic after permission status is known/updated - if (localHasPermission) { - // Permission is granted, now check location settings - val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, INTERVAL_MILLIS).build() - - val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest) - - val client = LocationServices.getSettingsClient(context) - val task = client.checkLocationSettings(builder.build()) - - task.addOnSuccessListener { - Logger.d { "Location settings are satisfied." } - onPermissionResult(true) // Permission granted and settings are good - } - - task.addOnFailureListener { exception -> - if (exception is ResolvableApiException) { - try { - val intentSenderRequest = IntentSenderRequest.Builder(exception.resolution).build() - locationSettingsLauncher.launch(intentSenderRequest) - // Result of this launch will be handled by locationSettingsLauncher's callback - } catch (sendEx: ActivityNotFoundException) { - Logger.d { "Error launching location settings resolution ${sendEx.message}." } - onPermissionResult(true) // Permission is granted, but settings dialog failed. Proceed. - } - } else { - Logger.d { "Location settings are not satisfiable.${exception.message}" } - onPermissionResult(true) // Permission is granted, but settings not ideal. Proceed. - } - } - } else { - // If permission is not granted, report false. - // This case is primarily handled by the requestLocationPermissionLauncher's callback - // if the initial state was denied, or if user denies it. - onPermissionResult(false) - } - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt b/app/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt deleted file mode 100644 index 6ac756f6b..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map - -import android.database.sqlite.SQLiteDatabase -import com.google.android.gms.maps.model.Tile -import com.google.android.gms.maps.model.TileProvider -import java.io.File - -class MBTilesProvider(private val file: File) : - TileProvider, - AutoCloseable { - private var database: SQLiteDatabase? = null - - init { - openDatabase() - } - - private fun openDatabase() { - if (database == null && file.exists()) { - database = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READONLY) - } - } - - override fun getTile(x: Int, y: Int, zoom: Int): Tile? { - val db = database ?: return null - - var tile: Tile? = null - // Convert Google Maps y coordinate to standard TMS y coordinate - val tmsY = (1 shl zoom) - 1 - y - - val cursor = - db.rawQuery( - "SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?", - arrayOf(zoom.toString(), x.toString(), tmsY.toString()), - ) - - if (cursor.moveToFirst()) { - val tileData = cursor.getBlob(0) - tile = Tile(256, 256, tileData) - } - cursor.close() - - return tile ?: TileProvider.NO_TILE - } - - override fun close() { - database?.close() - database = null - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt deleted file mode 100644 index c8f2f3fee..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ /dev/null @@ -1,1125 +0,0 @@ -/* - * Copyright (c) 2025-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 . - */ -@file:Suppress("MagicNumber") - -package org.meshtastic.app.map - -import android.Manifest -import android.app.Activity -import android.content.Intent -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.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -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.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import co.touchlab.kermit.Logger -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.rememberMultiplePermissionsState -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.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.SphericalUtil -import com.google.maps.android.compose.CameraPositionState -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 com.google.maps.android.data.Layer -import com.google.maps.android.data.geojson.GeoJsonLayer -import com.google.maps.android.data.kml.KmlLayer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource -import org.json.JSONObject -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.app.map.component.ClusterItemsListDialog -import org.meshtastic.app.map.component.CustomMapLayersSheet -import org.meshtastic.app.map.component.CustomTileProviderManagerSheet -import org.meshtastic.app.map.component.EditWaypointDialog -import org.meshtastic.app.map.component.MapFilterDropdown -import org.meshtastic.app.map.component.MapTypeDropdown -import org.meshtastic.app.map.component.NodeClusterMarkers -import org.meshtastic.app.map.component.NodeMapFilterDropdown -import org.meshtastic.app.map.component.WaypointMarkers -import org.meshtastic.app.map.model.NodeClusterItem -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.TracerouteOverlay -import org.meshtastic.core.model.util.GeoConstants.DEG_D -import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG -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.resources.Res -import org.meshtastic.core.resources.alt -import org.meshtastic.core.resources.heading -import org.meshtastic.core.resources.latitude -import org.meshtastic.core.resources.longitude -import org.meshtastic.core.resources.manage_map_layers -import org.meshtastic.core.resources.map_tile_source -import org.meshtastic.core.resources.position -import org.meshtastic.core.resources.sats -import org.meshtastic.core.resources.speed -import org.meshtastic.core.resources.timestamp -import org.meshtastic.core.resources.track_point -import org.meshtastic.core.ui.component.NodeChip -import org.meshtastic.core.ui.icon.Layers -import org.meshtastic.core.ui.icon.Map -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.TripOrigin -import org.meshtastic.core.ui.theme.TracerouteColors -import org.meshtastic.core.ui.util.formatAgo -import org.meshtastic.core.ui.util.formatPositionTime -import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState -import org.meshtastic.feature.map.LastHeardFilter -import org.meshtastic.feature.map.component.MapButton -import org.meshtastic.feature.map.component.MapControlsOverlay -import org.meshtastic.feature.map.tracerouteNodeSelection -import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits -import org.meshtastic.proto.Position -import org.meshtastic.proto.Waypoint -import kotlin.math.abs -import kotlin.math.max - -// region --- Map Mode --- - -/** - * Discriminated mode for [MapView] — replaces the original pile of nullable parameters with a type-safe sealed - * hierarchy. Each mode carries only the data it needs; the shared infrastructure (location tracking, tile providers, - * controls overlay) is available in every mode. - */ -sealed interface GoogleMapMode { - /** Standard map: node clusters, waypoints, custom layers, waypoint editing. */ - data object Main : GoogleMapMode - - /** Focused node position track: polyline + gradient markers for historical positions. */ - data class NodeTrack( - val focusedNode: Node?, - val positions: List, - val selectedPositionTime: Int? = null, - val onPositionSelected: ((Int) -> Unit)? = null, - ) : GoogleMapMode - - /** Traceroute visualization: offset forward/return polylines + hop markers. */ - data class Traceroute( - val overlay: TracerouteOverlay?, - val nodePositions: Map, - val onMappableCountChanged: (shown: Int, total: Int) -> Unit, - ) : GoogleMapMode -} - -// endregion - -private const val TRACEROUTE_OFFSET_METERS = 100.0 -private const val TRACEROUTE_BOUNDS_PADDING_PX = 120 - -@Suppress("CyclomaticComplexMethod", "LongMethod") -@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) -@Composable -fun MapView( - modifier: Modifier = Modifier, - mapViewModel: MapViewModel = koinViewModel(), - navigateToNodeDetails: (Int) -> Unit = {}, - mode: GoogleMapMode = GoogleMapMode.Main, -) { - val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() - val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle() - - // --- Location permissions --- - val locationPermissionsState = - rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) - var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) } - - // --- Location tracking --- - var isLocationTrackingEnabled by remember { mutableStateOf(false) } - var followPhoneBearing by remember { mutableStateOf(false) } - - LaunchedEffect(locationPermissionsState.allPermissionsGranted) { - if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) { - isLocationTrackingEnabled = true - triggerLocationToggleAfterPermission = false - } - } - - // --- File picker for map layers (Main mode) --- - val filePickerLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { - result.data?.data?.let { uri -> - val fileName = uri.getFileName(context) - mapViewModel.addMapLayer(uri, fileName) - } - } - } - - // --- UI state --- - var mapFilterMenuExpanded by remember { mutableStateOf(false) } - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() - var editingWaypoint by remember { mutableStateOf(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) } - - // --- Camera --- - // Main mode persists camera; NodeTrack/Traceroute use ephemeral state with auto-centering. - val cameraPositionState = - if (mode is GoogleMapMode.Main) mapViewModel.cameraPositionState else rememberCameraPositionState() - - if (mode is GoogleMapMode.Main) { - LaunchedEffect(cameraPositionState.isMoving) { - if (!cameraPositionState.isMoving) { - mapViewModel.saveCameraPosition(cameraPositionState.position) - } - } - } - - // --- FusedLocation --- - 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) { - Logger.d { "Error animating camera to location: ${e.message}" } - } - } - } - } - } - } - } - - LaunchedEffect(isLocationTrackingEnabled, locationPermissionsState.allPermissionsGranted) { - if (isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted) { - val locationRequest = - LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L) - .setMinUpdateIntervalMillis(2000L) - .build() - try { - @Suppress("MissingPermission") - fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null) - Logger.d { "Started location tracking" } - } catch (e: SecurityException) { - Logger.d { "Location permission not available: ${e.message}" } - isLocationTrackingEnabled = false - } - } else { - fusedLocationClient.removeLocationUpdates(locationCallback) - Logger.d { "Stopped location tracking" } - } - } - - DisposableEffect(Unit) { onDispose { fusedLocationClient.removeLocationUpdates(locationCallback) } } - - // --- Node & waypoint data --- - val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf()) - val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) - val displayableWaypoints = waypoints.values.mapNotNull { it.waypoint } - val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle() - - val filteredNodes = - allNodes - .filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num } - .filter { node -> - mapFilterState.lastHeardFilter.seconds == 0L || - (nowSeconds - node.lastHeard) <= mapFilterState.lastHeardFilter.seconds || - node.num == ourNodeInfo?.num - } - - val myNodeNum = mapViewModel.myNodeNum - 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 = if (dark) ComposeMapColorScheme.DARK else ComposeMapColorScheme.LIGHT - - // --- Mode-specific data --- - // Node track: apply time filter - val sortedTrackPositions = - if (mode is GoogleMapMode.NodeTrack) { - val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter - remember(mode.positions, lastHeardTrackFilter) { - mode.positions - .filter { - lastHeardTrackFilter == LastHeardFilter.Any || - it.time > nowSeconds - lastHeardTrackFilter.seconds - } - .sortedBy { it.time } - } - } else { - emptyList() - } - - // Traceroute: resolve node selection + polylines. Collected unconditionally per Compose rules - // (composable calls cannot be conditional), but only consumed in Traceroute mode. Uses all - // nodes, not just those with positions, so getNodeOrFallback can resolve metadata for hops - // whose positions come from snapshots. - val allNodesForTraceroute by mapViewModel.nodes.collectAsStateWithLifecycle(listOf()) - val tracerouteSelection = - if (mode is GoogleMapMode.Traceroute) { - remember(mode.overlay, mode.nodePositions, allNodesForTraceroute) { - mapViewModel.tracerouteNodeSelection( - tracerouteOverlay = mode.overlay, - tracerouteNodePositions = mode.nodePositions, - nodes = allNodesForTraceroute, - ) - } - } else { - null - } - val tracerouteDisplayNodes = tracerouteSelection?.nodesForMarkers ?: emptyList() - - if (mode is GoogleMapMode.Traceroute) { - LaunchedEffect(mode.overlay, tracerouteDisplayNodes) { - if (mode.overlay != null) { - mode.onMappableCountChanged(tracerouteDisplayNodes.size, mode.overlay.relatedNodeNums.size) - } - } - } - - val tracerouteForwardPoints: List = - if (mode is GoogleMapMode.Traceroute && tracerouteSelection != null) { - val nodeLookup = tracerouteSelection.nodeLookup - remember(mode.overlay, nodeLookup) { - mode.overlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList() - } - } else { - emptyList() - } - val tracerouteReturnPoints: List = - if (mode is GoogleMapMode.Traceroute && tracerouteSelection != null) { - val nodeLookup = tracerouteSelection.nodeLookup - remember(mode.overlay, nodeLookup) { - mode.overlay?.returnRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList() - } - } else { - emptyList() - } - val tracerouteHeadingReferencePoints = - remember(tracerouteForwardPoints, tracerouteReturnPoints) { - when { - tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints - tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints - else -> emptyList() - } - } - val tracerouteForwardOffsetPoints = - remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) { - offsetPolyline(tracerouteForwardPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, 1.0) - } - val tracerouteReturnOffsetPoints = - remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) { - offsetPolyline(tracerouteReturnPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, -1.0) - } - - // Auto-centering for NodeTrack / Traceroute modes - var hasCentered by remember(mode) { mutableStateOf(false) } - - if (mode is GoogleMapMode.NodeTrack) { - LaunchedEffect(sortedTrackPositions, hasCentered) { - if (hasCentered || sortedTrackPositions.isEmpty()) return@LaunchedEffect - val points = sortedTrackPositions.map { it.toLatLng() } - val cameraUpdate = - if (points.size == 1) { - CameraUpdateFactory.newLatLngZoom(points.first(), max(cameraPositionState.position.zoom, 12f)) - } else { - val bounds = LatLngBounds.builder() - points.forEach { bounds.include(it) } - CameraUpdateFactory.newLatLngBounds(bounds.build(), 80) - } - try { - cameraPositionState.animate(cameraUpdate) - hasCentered = true - } catch (e: IllegalStateException) { - Logger.d { "Error centering track map: ${e.message}" } - } - } - - // Animate to selected position marker when card is tapped in the list - LaunchedEffect(mode.selectedPositionTime) { - val selectedTime = mode.selectedPositionTime ?: return@LaunchedEffect - val selectedPos = sortedTrackPositions.find { it.time == selectedTime } ?: return@LaunchedEffect - try { - cameraPositionState.animate(CameraUpdateFactory.newLatLng(selectedPos.toLatLng())) - } catch (e: IllegalStateException) { - Logger.d { "Error animating to selected position: ${e.message}" } - } - } - } - - if (mode is GoogleMapMode.Traceroute) { - LaunchedEffect(mode.overlay, tracerouteForwardPoints, tracerouteReturnPoints) { - if (mode.overlay == null || hasCentered) return@LaunchedEffect - val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct() - if (allPoints.isNotEmpty()) { - val cameraUpdate = - if (allPoints.size == 1) { - CameraUpdateFactory.newLatLngZoom( - allPoints.first(), - max(cameraPositionState.position.zoom, 12f), - ) - } else { - val bounds = LatLngBounds.builder() - allPoints.forEach { bounds.include(it) } - CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX) - } - try { - cameraPositionState.animate(cameraUpdate) - hasCentered = true - } catch (e: IllegalStateException) { - Logger.d { "Error centering traceroute overlay: ${e.message}" } - } - } - } - } - - // --- Tile & layers state --- - 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?>(null) } - - // --- Keep screen on while location tracking --- - 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) - } - } - - // --- Main UI --- - val isMainMode = mode is GoogleMapMode.Main - - Box(modifier = modifier) { - GoogleMap( - mapColorScheme = mapColorScheme, - modifier = Modifier.fillMaxSize(), - cameraPositionState = cameraPositionState, - uiSettings = - MapUiSettings( - zoomControlsEnabled = true, - mapToolbarEnabled = isMainMode, - compassEnabled = false, - myLocationButtonEnabled = false, - rotationGesturesEnabled = true, - scrollGesturesEnabled = true, - tiltGesturesEnabled = isMainMode, - zoomGesturesEnabled = true, - ), - properties = - MapProperties( - mapType = effectiveGoogleMapType, - isMyLocationEnabled = isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted, - ), - onMapLongClick = { latLng -> - if (isMainMode && isConnected) { - editingWaypoint = - Waypoint( - latitude_i = (latLng.latitude / DEG_D).toInt(), - longitude_i = (latLng.longitude / DEG_D).toInt(), - ) - } - }, - ) { - // Custom tile overlay (all modes) - key(currentCustomTileProviderUrl) { - currentCustomTileProviderUrl?.let { url -> - val config = - mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find { - it.urlTemplate == url || it.localUri == url - } - mapViewModel.getTileProvider(config)?.let { tileProvider -> - TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f) - } - } - } - - when (mode) { - is GoogleMapMode.Main -> - MainMapContent( - nodeClusterItems = - filteredNodes.map { node -> - val latLng = - LatLng( - (node.position.latitude_i ?: 0) * DEG_D, - (node.position.longitude_i ?: 0) * DEG_D, - ) - NodeClusterItem( - node = node, - nodePosition = latLng, - nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}", - nodeSnippet = "${node.user.long_name}", - myNodeNum = myNodeNum, - ) - }, - mapFilterState = mapFilterState, - navigateToNodeDetails = navigateToNodeDetails, - displayableWaypoints = displayableWaypoints, - myNodeNum = myNodeNum, - isConnected = isConnected, - onEditWaypointRequest = { editingWaypoint = it }, - selectedWaypointId = selectedWaypointId, - mapLayers = mapLayers, - mapViewModel = mapViewModel, - cameraPositionState = cameraPositionState, - coroutineScope = coroutineScope, - onShowClusterItemsDialog = { showClusterItemsDialog = it }, - ) - - is GoogleMapMode.NodeTrack -> { - val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle() - if (mode.focusedNode != null && sortedTrackPositions.isNotEmpty()) { - NodeTrackOverlay( - focusedNode = mode.focusedNode, - sortedPositions = sortedTrackPositions, - displayUnits = displayUnits, - myNodeNum = myNodeNum, - selectedPositionTime = mode.selectedPositionTime, - onPositionSelected = mode.onPositionSelected, - ) - } - } - - is GoogleMapMode.Traceroute -> - TracerouteMapContent( - forwardOffsetPoints = tracerouteForwardOffsetPoints, - returnOffsetPoints = tracerouteReturnOffsetPoints, - forwardPointCount = tracerouteForwardPoints.size, - returnPointCount = tracerouteReturnPoints.size, - displayNodes = tracerouteDisplayNodes, - ) - } - } - - // Scale bar - ScaleBar( - cameraPositionState = cameraPositionState, - modifier = Modifier.align(Alignment.BottomStart).padding(bottom = if (isMainMode) 48.dp else 16.dp), - ) - - // Waypoint edit dialog (Main mode only) - if (isMainMode) { - 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) == 0) { - finalWp = finalWp.copy(icon = 0x1F4CD) - } - mapViewModel.sendWaypoint(finalWp) - editingWaypoint = null - }, - onDeleteClicked = { wpToDelete -> - if ((wpToDelete.locked_to ?: 0) == 0 && isConnected && wpToDelete.id != 0) { - mapViewModel.sendWaypoint(wpToDelete.copy(expire = 1)) - } - mapViewModel.deleteWaypoint(wpToDelete.id) - editingWaypoint = null - }, - onDismissRequest = { editingWaypoint = null }, - ) - } - } - - // Controls overlay - val visibleNetworkLayers = mapLayers.filter { it.isNetwork && it.isVisible } - val showRefresh = visibleNetworkLayers.isNotEmpty() - val isRefreshingLayers = visibleNetworkLayers.any { it.isRefreshing } - - MapControlsOverlay( - modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), - onToggleFilterMenu = { mapFilterMenuExpanded = true }, - filterDropdownContent = { - if (mode is GoogleMapMode.NodeTrack) { - NodeMapFilterDropdown( - expanded = mapFilterMenuExpanded, - onDismissRequest = { mapFilterMenuExpanded = false }, - mapViewModel = mapViewModel, - ) - } else { - MapFilterDropdown( - expanded = mapFilterMenuExpanded, - onDismissRequest = { mapFilterMenuExpanded = false }, - mapViewModel = mapViewModel, - ) - } - }, - mapTypeContent = { - Box { - MapButton( - icon = MeshtasticIcons.Map, - contentDescription = stringResource(Res.string.map_tile_source), - onClick = { mapTypeMenuExpanded = true }, - ) - MapTypeDropdown( - expanded = mapTypeMenuExpanded, - onDismissRequest = { mapTypeMenuExpanded = false }, - mapViewModel = mapViewModel, - onManageCustomTileProvidersClicked = { - mapTypeMenuExpanded = false - showCustomTileManagerSheet = true - }, - ) - } - }, - layersContent = { - MapButton( - icon = MeshtasticIcons.Layers, - contentDescription = stringResource(Res.string.manage_map_layers), - onClick = { showLayersBottomSheet = true }, - ) - }, - isLocationTrackingEnabled = isLocationTrackingEnabled, - onToggleLocationTracking = { - if (locationPermissionsState.allPermissionsGranted) { - isLocationTrackingEnabled = !isLocationTrackingEnabled - if (!isLocationTrackingEnabled) { - followPhoneBearing = false - } - } else { - triggerLocationToggleAfterPermission = true - locationPermissionsState.launchMultiplePermissionRequest() - } - }, - 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)) - Logger.d { "Oriented map to north" } - } catch (e: IllegalStateException) { - Logger.d { "Error orienting map to north: ${e.message}" } - } - } - } - }, - followPhoneBearing = followPhoneBearing, - showRefresh = showRefresh, - isRefreshing = isRefreshingLayers, - onRefresh = { mapViewModel.refreshAllVisibleNetworkLayers() }, - ) - } - - // --- Bottom sheets & dialogs --- - if (showLayersBottomSheet) { - ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) { - CustomMapLayersSheet( - mapLayers = mapLayers, - onToggleVisibility = onToggleVisibility, - onRemoveLayer = onRemoveLayer, - onAddLayerClicked = onAddLayerClicked, - onRefreshLayer = { mapViewModel.refreshMapLayer(it) }, - onAddNetworkLayer = { name, url -> mapViewModel.addNetworkMapLayer(name, url) }, - ) - } - } - 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) - } - } -} - -// region --- Main Map Content --- - -@Suppress("LongParameterList") -@OptIn(MapsComposeExperimentalApi::class) -@Composable -private fun MainMapContent( - nodeClusterItems: List, - mapFilterState: MapFilterState, - navigateToNodeDetails: (Int) -> Unit, - displayableWaypoints: List, - myNodeNum: Int?, - isConnected: Boolean, - onEditWaypointRequest: (Waypoint) -> Unit, - selectedWaypointId: Int?, - mapLayers: List, - mapViewModel: MapViewModel, - cameraPositionState: CameraPositionState, - coroutineScope: CoroutineScope, - onShowClusterItemsDialog: (List?) -> Unit, -) { - 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) { - onShowClusterItemsDialog(items) - } else { - val bounds = LatLngBounds.builder() - cluster.items.forEach { bounds.include(it.position) } - coroutineScope.launch { - cameraPositionState.animate( - CameraUpdateFactory.newCameraPosition( - CameraPosition.Builder() - .target(bounds.build().center) - .zoom(cameraPositionState.position.zoom + 1) - .build(), - ), - ) - } - Logger.d { "Cluster clicked! $cluster" } - } - true - }, - ) - - WaypointMarkers( - displayableWaypoints = displayableWaypoints, - mapFilterState = mapFilterState, - myNodeNum = myNodeNum ?: 0, - isConnected = isConnected, - onEditWaypointRequest = onEditWaypointRequest, - selectedWaypointId = selectedWaypointId, - ) - - mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } } -} - -// endregion - -// region --- Node Track Overlay --- - -/** - * Renders the position track polyline segments and markers inside a [GoogleMap] content scope. Each marker fades from - * transparent (oldest) to opaque (newest). The newest position shows the node's [NodeChip]; older positions show a - * [TripOrigin] dot with an info-window on tap. - * - * When [selectedPositionTime] matches a marker's `Position.time`, that marker is highlighted with the primary color and - * elevated z-index. Tapping a marker invokes [onPositionSelected] for list synchronization. - */ -@OptIn(MapsComposeExperimentalApi::class) -@Composable -@Suppress("LongMethod") -private fun NodeTrackOverlay( - focusedNode: Node, - sortedPositions: List, - displayUnits: DisplayUnits, - myNodeNum: Int?, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, -) { - val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite - val activeNodeZIndex = if (isHighPriority) 5f else 4f - val selectedColor = MaterialTheme.colorScheme.primary - - sortedPositions.forEachIndexed { index, position -> - key(position.time) { - val markerState = rememberUpdatedMarkerState(position = position.toLatLng()) - val alpha = - if (sortedPositions.size > 1) { - index.toFloat() / (sortedPositions.size.toFloat() - 1) - } else { - 1f - } - val isSelected = position.time == selectedPositionTime - val color = - if (isSelected) { - selectedColor - } else { - Color(focusedNode.colors.second).copy(alpha = alpha) - } - - if (index == sortedPositions.lastIndex) { - MarkerComposable( - state = markerState, - zIndex = activeNodeZIndex, - alpha = if (isHighPriority) 1.0f else 0.9f, - onClick = { - onPositionSelected?.invoke(position.time) - false // Allow default info window behavior - }, - ) { - NodeChip(node = focusedNode) - } - } else { - MarkerInfoWindowComposable( - state = markerState, - title = stringResource(Res.string.position), - snippet = formatAgo(position.time), - zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + alpha, - onClick = { - onPositionSelected?.invoke(position.time) - false // Allow default info window behavior - }, - infoContent = { PositionInfoWindowContent(position = position, displayUnits = displayUnits) }, - ) { - Icon( - imageVector = MeshtasticIcons.TripOrigin, - contentDescription = stringResource(Res.string.track_point), - tint = color, - modifier = if (isSelected) Modifier.size(32.dp) else Modifier, - ) - } - } - } - } - - // Gradient polyline segments - if (sortedPositions.size > 1) { - 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, - zIndex = 0.6f, - ) - } - } -} - -@Composable -@Suppress("LongMethod") -private fun PositionInfoWindowContent(position: Position, 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.labelMedium) - } - } - - Card { - Column(modifier = Modifier.padding(8.dp)) { - PositionRow( - label = stringResource(Res.string.latitude), - value = "%.5f".format((position.latitude_i ?: 0) * DEG_D), - ) - PositionRow( - label = stringResource(Res.string.longitude), - value = "%.5f".format((position.longitude_i ?: 0) * DEG_D), - ) - PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view.toString()) - PositionRow( - label = stringResource(Res.string.alt), - value = (position.altitude ?: 0).metersIn(displayUnits).toString(displayUnits), - ) - PositionRow(label = stringResource(Res.string.speed), value = speedFromPosition(position, displayUnits)) - PositionRow( - label = stringResource(Res.string.heading), - value = "%.0f°".format((position.ground_track ?: 0) * HEADING_DEG), - ) - PositionRow(label = stringResource(Res.string.timestamp), value = position.formatPositionTime()) - } - } -} - -@Composable -private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String { - val speedInMps = position.ground_speed ?: 0 - val mpsText = "%d m/s".format(speedInMps) - return if (speedInMps > 10) { - when (displayUnits) { - DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph()) - DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph()) - else -> mpsText - } - } else { - mpsText - } -} - -// endregion - -// region --- Traceroute Map Content --- - -@OptIn(MapsComposeExperimentalApi::class) -@Composable -private fun TracerouteMapContent( - forwardOffsetPoints: List, - returnOffsetPoints: List, - forwardPointCount: Int, - returnPointCount: Int, - displayNodes: List, -) { - if (forwardPointCount >= 2) { - Polyline( - points = forwardOffsetPoints, - jointType = JointType.ROUND, - color = TracerouteColors.OutgoingRoute, - width = 9f, - zIndex = 3.0f, - ) - } - if (returnPointCount >= 2) { - Polyline( - points = returnOffsetPoints, - jointType = JointType.ROUND, - color = TracerouteColors.ReturnRoute, - width = 7f, - zIndex = 2.5f, - ) - } - displayNodes.forEach { node -> - val markerState = rememberUpdatedMarkerState(position = node.position.toLatLng()) - MarkerComposable(state = markerState, zIndex = 4f) { NodeChip(node = node) } - } -} - -private fun offsetPolyline( - points: List, - offsetMeters: Double, - headingReferencePoints: List = points, - sideMultiplier: Double = 1.0, -): List { - val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points - if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points - - val headings = - headingPoints.mapIndexed { index, _ -> - when (index) { - 0 -> SphericalUtil.computeHeading(headingPoints[0], headingPoints[1]) - headingPoints.lastIndex -> - SphericalUtil.computeHeading( - headingPoints[headingPoints.lastIndex - 1], - headingPoints[headingPoints.lastIndex], - ) - - else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1]) - } - } - - return points.mapIndexed { index, point -> - val heading = headings[index.coerceIn(0, headings.lastIndex)] - val perpendicularHeading = heading + (90.0 * sideMultiplier) - SphericalUtil.computeOffset(point, abs(offsetMeters), perpendicularHeading) - } -} - -// endregion - -// region --- Map Layers --- - -@Composable -private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) { - val context = LocalContext.current - var currentLayer by remember { mutableStateOf(null) } - - MapEffect(layerItem.id, layerItem.isRefreshing) { map -> - currentLayer?.safeRemoveLayerFromMap() - currentLayer = null - val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect - val layer = - try { - when (layerItem.layerType) { - LayerType.KML -> KmlLayer(map, inputStream, context) - LayerType.GEOJSON -> - GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() })) - } - } catch (e: Exception) { - Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" } - null - } - layer?.let { - if (layerItem.isVisible) it.safeAddLayerToMap() - currentLayer = it - } - } - - DisposableEffect(layerItem.id) { - onDispose { - currentLayer?.safeRemoveLayerFromMap() - currentLayer = null - } - } - - LaunchedEffect(layerItem.isVisible) { - val layer = currentLayer ?: return@LaunchedEffect - if (layerItem.isVisible) layer.safeAddLayerToMap() else layer.safeRemoveLayerFromMap() - } -} - -private fun Layer.safeRemoveLayerFromMap() { - try { - removeLayerFromMap() - } catch (e: Exception) { - Logger.withTag("MapView").e(e) { "Error removing map layer" } - } -} - -private fun Layer.safeAddLayerToMap() { - try { - if (!isLayerOnMap) addLayerToMap() - } catch (e: Exception) { - Logger.withTag("MapView").e(e) { "Error adding map layer" } - } -} - -// endregion - -// region --- Utilities --- - -internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try { - String(Character.toChars(unicodeCodePoint)) -} catch (e: IllegalArgumentException) { - Logger.w(e) { "Invalid unicode code point: $unicodeCodePoint" } - "\uD83D\uDCCD" -} - -@Suppress("NestedBlockDepth") -fun Uri.getFileName(context: android.content.Context): String { - var name = this.lastPathSegment ?: "layer_$nowMillis" - 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 -} - -/** Converts protobuf [Position] integer coordinates to a Google Maps [LatLng]. */ -internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) - -// endregion diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt deleted file mode 100644 index 70ff4858d..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ /dev/null @@ -1,676 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map - -import android.app.Application -import android.net.Uri -import androidx.core.net.toFile -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import co.touchlab.kermit.Logger -import com.google.android.gms.maps.model.CameraPosition -import com.google.android.gms.maps.model.LatLng -import com.google.android.gms.maps.model.TileProvider -import com.google.android.gms.maps.model.UrlTileProvider -import com.google.maps.android.compose.CameraPositionState -import com.google.maps.android.compose.MapType -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import org.koin.core.annotation.KoinViewModel -import org.meshtastic.app.map.model.CustomTileProviderConfig -import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs -import org.meshtastic.app.map.repository.CustomTileProviderRepository -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.repository.MapPrefs -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.feature.map.BaseMapViewModel -import org.meshtastic.proto.Config -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 kotlin.uuid.Uuid - -private const val TILE_SIZE = 256 - -@Serializable -data class MapCameraPosition( - val targetLat: Double, - val targetLng: Double, - val zoom: Float, - val tilt: Float, - val bearing: Float, -) - -@Suppress("TooManyFunctions", "LongParameterList") -@KoinViewModel -class MapViewModel( - private val application: Application, - mapPrefs: MapPrefs, - private val googleMapsPrefs: GoogleMapsPrefs, - nodeRepository: NodeRepository, - packetRepository: PacketRepository, - radioConfigRepository: RadioConfigRepository, - radioController: RadioController, - private val customTileProviderRepository: CustomTileProviderRepository, - uiPrefs: UiPrefs, - savedStateHandle: SavedStateHandle, -) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) { - - private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId")) - val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow() - - fun setWaypointId(id: Int?) { - if (_selectedWaypointId.value != id) { - _selectedWaypointId.value = id - if (id != null) { - viewModelScope.launch { - val wpMap = waypoints.first { it.containsKey(id) } - wpMap[id]?.let { packet -> - val waypoint = packet.waypoint!! - val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7) - cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f) - } - } - } - } - } - - private val targetLatLng = - googleMapsPrefs.cameraTargetLat.value - .takeIf { it != 0.0 } - ?.let { lat -> googleMapsPrefs.cameraTargetLng.value.takeIf { it != 0.0 }?.let { lng -> LatLng(lat, lng) } } - ?: ourNodeInfo.value?.position?.toLatLng() - ?: LatLng(0.0, 0.0) - - val cameraPositionState = - CameraPositionState( - position = - CameraPosition( - targetLatLng, - googleMapsPrefs.cameraZoom.value, - googleMapsPrefs.cameraTilt.value, - googleMapsPrefs.cameraBearing.value, - ), - ) - - val theme: StateFlow = uiPrefs.theme - - private val _errorFlow = MutableSharedFlow() - val errorFlow: SharedFlow = _errorFlow.asSharedFlow() - - val customTileProviderConfigs: StateFlow> = - customTileProviderRepository.getCustomTileProviders().stateInWhileSubscribed(initialValue = emptyList()) - - private val _selectedCustomTileProviderUrl = MutableStateFlow(null) - val selectedCustomTileProviderUrl: StateFlow = _selectedCustomTileProviderUrl.asStateFlow() - - private val _selectedGoogleMapType = MutableStateFlow(MapType.NORMAL) - val selectedGoogleMapType: StateFlow = _selectedGoogleMapType.asStateFlow() - - val displayUnits = - radioConfigRepository.deviceProfileFlow - .mapNotNull { it.config?.display?.units } - .stateInWhileSubscribed(initialValue = Config.DisplayConfig.DisplayUnits.METRIC) - - fun addCustomTileProvider(name: String, urlTemplate: String, localUri: String? = null) { - viewModelScope.launch { - if ( - name.isBlank() || - (urlTemplate.isBlank() && localUri == null) || - (localUri == null && !isValidTileUrlTemplate(urlTemplate)) - ) { - _errorFlow.emit("Invalid name, URL template, or local URI 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 - } - - var finalLocalUri = localUri - if (localUri != null) { - try { - val uri = Uri.parse(localUri) - val extension = "mbtiles" - val finalFileName = "mbtiles_${Uuid.random()}.$extension" - val copiedUri = copyFileToInternalStorage(uri, finalFileName) - if (copiedUri != null) { - finalLocalUri = copiedUri.toString() - } else { - _errorFlow.emit("Failed to copy MBTiles file to internal storage.") - return@launch - } - } catch (e: Exception) { - Logger.withTag("MapViewModel").e(e) { "Error processing local URI" } - _errorFlow.emit("Error processing local URI for MBTiles.") - return@launch - } - } - - val newConfig = CustomTileProviderConfig(name = name, urlTemplate = urlTemplate, localUri = finalLocalUri) - customTileProviderRepository.addCustomTileProvider(newConfig) - } - } - - fun updateCustomTileProvider(configToUpdate: CustomTileProviderConfig) { - viewModelScope.launch { - if ( - configToUpdate.name.isBlank() || - (configToUpdate.urlTemplate.isBlank() && configToUpdate.localUri == null) || - (configToUpdate.localUri == null && !isValidTileUrlTemplate(configToUpdate.urlTemplate)) - ) { - _errorFlow.emit("Invalid name, URL template, or local URI 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) { - if ( - _selectedCustomTileProviderUrl.value == configToRemove.urlTemplate || - _selectedCustomTileProviderUrl.value == configToRemove.localUri - ) { - _selectedCustomTileProviderUrl.value = null - // Also clear from prefs - googleMapsPrefs.setSelectedCustomTileUrl(null) - } - - if (configToRemove.localUri != null) { - val uri = Uri.parse(configToRemove.localUri) - deleteFileToInternalStorage(uri) - } - } - } - } - - fun selectCustomTileProvider(config: CustomTileProviderConfig?) { - if (config != null) { - if (!config.isLocal && !isValidTileUrlTemplate(config.urlTemplate)) { - Logger.withTag("MapViewModel").w("Attempted to select invalid URL template: ${config.urlTemplate}") - _selectedCustomTileProviderUrl.value = null - googleMapsPrefs.setSelectedCustomTileUrl(null) - return - } - // Use localUri if present, otherwise urlTemplate - val selectedUrl = config.localUri ?: config.urlTemplate - _selectedCustomTileProviderUrl.value = selectedUrl - _selectedGoogleMapType.value = MapType.NONE - googleMapsPrefs.setSelectedCustomTileUrl(selectedUrl) - googleMapsPrefs.setSelectedGoogleMapType(null) - } else { - _selectedCustomTileProviderUrl.value = null - _selectedGoogleMapType.value = MapType.NORMAL - googleMapsPrefs.setSelectedCustomTileUrl(null) - googleMapsPrefs.setSelectedGoogleMapType(MapType.NORMAL.name) - } - } - - fun setSelectedGoogleMapType(mapType: MapType) { - _selectedGoogleMapType.value = mapType - _selectedCustomTileProviderUrl.value = null // Clear custom selection - googleMapsPrefs.setSelectedGoogleMapType(mapType.name) - googleMapsPrefs.setSelectedCustomTileUrl(null) - } - - private var currentTileProvider: TileProvider? = null - - fun getTileProvider(config: CustomTileProviderConfig?): TileProvider? { - if (config == null) { - (currentTileProvider as? MBTilesProvider)?.close() - currentTileProvider = null - return null - } - - val selectedUrl = config.localUri ?: config.urlTemplate - if (currentTileProvider != null && _selectedCustomTileProviderUrl.value == selectedUrl) { - return currentTileProvider - } - - // Close previous if it was a local provider - (currentTileProvider as? MBTilesProvider)?.close() - - val newProvider = - if (config.isLocal) { - val uri = Uri.parse(config.localUri) - val file = - try { - uri.toFile() - } catch (e: Exception) { - File(uri.path ?: "") - } - if (file.exists()) { - MBTilesProvider(file) - } else { - Logger.withTag("MapViewModel").e("Local MBTiles file does not exist: ${config.localUri}") - null - } - } else { - val urlString = config.urlTemplate - if (!isValidTileUrlTemplate(urlString)) { - Logger.withTag("MapViewModel") - .e("Tile URL does not contain valid {x}, {y}, and {z} placeholders: $urlString") - null - } else { - object : UrlTileProvider(TILE_SIZE, TILE_SIZE) { - override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? { - val subdomains = listOf("a", "b", "c") - val subdomain = subdomains[(x + y) % subdomains.size] - val formattedUrl = - urlString - .replace("{s}", subdomain, ignoreCase = true) - .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) { - Logger.withTag("MapViewModel").e(e) { "Malformed URL: $formattedUrl" } - null - } - } - } - } - } - - currentTileProvider = newProvider - return newProvider - } - - 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>(emptyList()) - val mapLayers: StateFlow> = _mapLayers.asStateFlow() - - init { - viewModelScope.launch { - customTileProviderRepository.getCustomTileProviders().first() - loadPersistedMapType() - } - loadPersistedLayers() - - selectedWaypointId.value?.let { wpId -> - viewModelScope.launch { - val wpMap = waypoints.first { it.containsKey(wpId) } - wpMap[wpId]?.let { packet -> - val waypoint = packet.waypoint!! - val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7) - cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f) - } - } - } - } - - fun saveCameraPosition(cameraPosition: CameraPosition) { - viewModelScope.launch { - googleMapsPrefs.setCameraTargetLat(cameraPosition.target.latitude) - googleMapsPrefs.setCameraTargetLng(cameraPosition.target.longitude) - googleMapsPrefs.setCameraZoom(cameraPosition.zoom) - googleMapsPrefs.setCameraTilt(cameraPosition.tilt) - googleMapsPrefs.setCameraBearing(cameraPosition.bearing) - } - } - - private fun loadPersistedMapType() { - val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl.value - if (savedCustomUrl != null) { - // Check if this custom provider still exists - if ( - customTileProviderConfigs.value.any { it.urlTemplate == savedCustomUrl } && - isValidTileUrlTemplate(savedCustomUrl) - ) { - _selectedCustomTileProviderUrl.value = savedCustomUrl - _selectedGoogleMapType.value = - MapType.NONE // MapType.NONE to hide google basemap when using custom provider - } else { - // The saved custom URL is no longer valid or doesn't exist, remove preference - googleMapsPrefs.setSelectedCustomTileUrl(null) - // Fallback to default Google Map type - _selectedGoogleMapType.value = MapType.NORMAL - } - } else { - val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType.value - try { - _selectedGoogleMapType.value = MapType.valueOf(savedGoogleMapTypeName ?: MapType.NORMAL.name) - } catch (e: IllegalArgumentException) { - Logger.e(e) { "Invalid saved Google Map type: $savedGoogleMapTypeName" } - _selectedGoogleMapType.value = MapType.NORMAL // Fallback in case of invalid stored name - googleMapsPrefs.setSelectedGoogleMapType(null) - } - } - } - - private fun loadPersistedLayers() { - viewModelScope.launch(Dispatchers.IO) { - try { - val layersDir = File(application.filesDir, "map_layers") - if (layersDir.exists() && layersDir.isDirectory) { - val persistedLayerFiles = layersDir.listFiles() - - if (persistedLayerFiles != null) { - val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value - val loadedItems = persistedLayerFiles.mapNotNull { file -> - if (file.isFile) { - val layerType = - when (file.extension.lowercase()) { - "kml", - "kmz", - -> LayerType.KML - "geojson", - "json", - -> LayerType.GEOJSON - else -> null - } - - layerType?.let { - val uri = Uri.fromFile(file) - MapLayerItem( - name = file.nameWithoutExtension, - uri = uri, - isVisible = !hiddenLayerUrls.contains(uri.toString()), - layerType = it, - ) - } - } else { - null - } - } - - val networkItems = - googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString -> - try { - val parts = networkString.split("|:|") - if (parts.size == 3) { - val id = parts[0] - val name = parts[1] - val uri = Uri.parse(parts[2]) - MapLayerItem( - id = id, - name = name, - uri = uri, - isVisible = !hiddenLayerUrls.contains(uri.toString()), - layerType = LayerType.KML, - isNetwork = true, - ) - } else { - null - } - } catch (e: Exception) { - null - } - } - - _mapLayers.value = loadedItems + networkItems - if (_mapLayers.value.isNotEmpty()) { - Logger.withTag("MapViewModel").i("Loaded ${_mapLayers.value.size} persisted map layers.") - } - } - } else { - Logger.withTag("MapViewModel").i("Map layers directory does not exist. No layers loaded.") - } - } catch (e: Exception) { - Logger.withTag("MapViewModel").e(e) { "Error loading persisted map layers" } - _mapLayers.value = emptyList() - } - } - } - - fun addMapLayer(uri: Uri, fileName: String?) { - viewModelScope.launch { - val layerName = fileName?.substringBeforeLast('.') ?: "Layer ${mapLayers.value.size + 1}" - - val extension = - fileName?.substringAfterLast('.', "")?.lowercase() - ?: application.contentResolver.getType(uri)?.split('/')?.last() - - val kmlExtensions = listOf("kml", "kmz", "vnd.google-earth.kml+xml", "vnd.google-earth.kmz") - val geoJsonExtensions = listOf("geojson", "json") - - val layerType = - when (extension) { - in kmlExtensions -> LayerType.KML - in geoJsonExtensions -> LayerType.GEOJSON - else -> null - } - - if (layerType == null) { - Logger.withTag("MapViewModel").e("Unsupported map layer file type: $extension") - return@launch - } - - val finalFileName = - if (fileName != null) { - "$layerName.$extension" - } else { - "layer_${Uuid.random()}.$extension" - } - - val localFileUri = copyFileToInternalStorage(uri, finalFileName) - - if (localFileUri != null) { - val newItem = MapLayerItem(name = layerName, uri = localFileUri, layerType = layerType) - _mapLayers.value = _mapLayers.value + newItem - } else { - Logger.withTag("MapViewModel").e("Failed to copy file to internal storage.") - } - } - } - - fun addNetworkMapLayer(name: String, url: String) { - viewModelScope.launch { - if (name.isBlank() || url.isBlank()) { - _errorFlow.emit("Invalid name or URL for network layer.") - return@launch - } - try { - val uri = Uri.parse(url) - if (uri.scheme != "http" && uri.scheme != "https") { - _errorFlow.emit("URL must be http or https.") - return@launch - } - - val path = uri.path?.lowercase() ?: "" - val layerType = - when { - path.endsWith(".geojson") || path.endsWith(".json") -> LayerType.GEOJSON - else -> LayerType.KML // Default to KML - } - - val newItem = MapLayerItem(name = name, uri = uri, layerType = layerType, isNetwork = true) - _mapLayers.value = _mapLayers.value + newItem - - val networkLayerString = "${newItem.id}|:|${newItem.name}|:|${newItem.uri}" - googleMapsPrefs.setNetworkMapLayers(googleMapsPrefs.networkMapLayers.value + networkLayerString) - } catch (e: Exception) { - _errorFlow.emit("Invalid URL.") - } - } - } - - 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) { - Logger.withTag("MapViewModel").e(e) { "Error copying file to internal storage" } - null - } - } - - fun toggleLayerVisibility(layerId: String) { - var toggledLayer: MapLayerItem? = null - val updatedLayers = - _mapLayers.value.map { - if (it.id == layerId) { - toggledLayer = it.copy(isVisible = !it.isVisible) - toggledLayer - } else { - it - } - } - _mapLayers.value = updatedLayers - - toggledLayer?.let { - if (it.isVisible) { - googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - it.uri.toString()) - } else { - googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value + it.uri.toString()) - } - } - } - - fun removeMapLayer(layerId: String) { - viewModelScope.launch { - val layerToRemove = _mapLayers.value.find { it.id == layerId } - layerToRemove?.uri?.let { uri -> - if (layerToRemove.isNetwork) { - googleMapsPrefs.setNetworkMapLayers( - googleMapsPrefs.networkMapLayers.value.filterNot { it.startsWith("$layerId|:|") }.toSet(), - ) - } else { - deleteFileToInternalStorage(uri) - } - googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - uri.toString()) - } - _mapLayers.value = _mapLayers.value.filterNot { it.id == layerId } - } - } - - fun refreshMapLayer(layerId: String) { - viewModelScope.launch { - _mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = true) else it } } - // By resetting the layer data in the UI (implied by just refreshing), - // we trigger a reload in the Composable. - _mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = false) else it } } - } - } - - fun refreshAllVisibleNetworkLayers() { - _mapLayers.value.filter { it.isNetwork && it.isVisible }.forEach { refreshMapLayer(it.id) } - } - - private suspend fun deleteFileToInternalStorage(uri: Uri) { - withContext(Dispatchers.IO) { - try { - val file = uri.toFile() - if (file.exists()) { - file.delete() - } - } catch (e: Exception) { - Logger.withTag("MapViewModel").e(e) { "Error deleting file from internal storage" } - } - } - } - - @Suppress("Recycle") - suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? { - val uriToLoad = layerItem.uri ?: return null - return withContext(Dispatchers.IO) { - try { - if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) { - val url = java.net.URL(uriToLoad.toString()) - java.io.BufferedInputStream(url.openStream()) - } else { - application.contentResolver.openInputStream(uriToLoad) - } - } catch (e: Exception) { - Logger.withTag("MapViewModel").e(e) { "Error opening InputStream from URI: $uriToLoad" } - null - } - } - } - - override fun onCleared() { - super.onCleared() - (currentTileProvider as? MBTilesProvider)?.close() - } - - override fun getUser(userId: String?) = - nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST) -} - -enum class LayerType { - KML, - GEOJSON, -} - -data class MapLayerItem( - val id: String = Uuid.random().toString(), - val name: String, - val uri: Uri? = null, - val isVisible: Boolean = true, - val layerType: LayerType, - val isNetwork: Boolean = false, - val isRefreshing: Boolean = false, -) diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt deleted file mode 100644 index 5c5e325ac..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.component - -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.unit.dp -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.model.NodeClusterItem -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.nodes_at_this_location -import org.meshtastic.core.resources.okay -import org.meshtastic.core.ui.component.NodeChip - -@Composable -fun ClusterItemsListDialog( - items: List, - onDismiss: () -> Unit, - onItemClick: (NodeClusterItem) -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(text = stringResource(Res.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(Res.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 - ) -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt deleted file mode 100644 index fd9272579..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.component - -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.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconToggleButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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.unit.dp -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.MapLayerItem -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.add_layer -import org.meshtastic.core.resources.add_network_layer -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.hide_layer -import org.meshtastic.core.resources.manage_map_layers -import org.meshtastic.core.resources.map_layer_formats -import org.meshtastic.core.resources.name -import org.meshtastic.core.resources.network_layer_url_hint -import org.meshtastic.core.resources.no_map_layers_loaded -import org.meshtastic.core.resources.refresh -import org.meshtastic.core.resources.remove_layer -import org.meshtastic.core.resources.save -import org.meshtastic.core.resources.show_layer -import org.meshtastic.core.resources.url -import org.meshtastic.core.ui.component.MeshtasticDialog -import org.meshtastic.core.ui.icon.Delete -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Refresh -import org.meshtastic.core.ui.icon.Visibility -import org.meshtastic.core.ui.icon.VisibilityOff - -@Suppress("LongMethod") -@Composable -@OptIn(ExperimentalMaterial3Api::class) -fun CustomMapLayersSheet( - mapLayers: List, - onToggleVisibility: (String) -> Unit, - onRemoveLayer: (String) -> Unit, - onAddLayerClicked: () -> Unit, - onRefreshLayer: (String) -> Unit, - onAddNetworkLayer: (String, String) -> Unit, -) { - var showAddNetworkLayerDialog by remember { mutableStateOf(false) } - LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { - item { - Text( - modifier = Modifier.padding(16.dp), - text = stringResource(Res.string.manage_map_layers), - style = MaterialTheme.typography.headlineSmall, - ) - HorizontalDivider() - } - item { - Text( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 0.dp), - text = stringResource(Res.string.map_layer_formats), - style = MaterialTheme.typography.bodySmall, - ) - } - - if (mapLayers.isEmpty()) { - item { - Text( - modifier = Modifier.padding(16.dp), - text = stringResource(Res.string.no_map_layers_loaded), - style = MaterialTheme.typography.bodyMedium, - ) - } - } else { - items(mapLayers, key = { it.id }) { layer -> - ListItem( - headlineContent = { Text(layer.name) }, - trailingContent = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (layer.isNetwork) { - if (layer.isRefreshing) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp).padding(4.dp), - strokeWidth = 2.dp, - ) - } else { - IconButton(onClick = { onRefreshLayer(layer.id) }) { - Icon( - imageVector = MeshtasticIcons.Refresh, - contentDescription = stringResource(Res.string.refresh), - ) - } - } - } - IconToggleButton( - checked = layer.isVisible, - onCheckedChange = { onToggleVisibility(layer.id) }, - ) { - Icon( - imageVector = - if (layer.isVisible) { - MeshtasticIcons.Visibility - } else { - MeshtasticIcons.VisibilityOff - }, - contentDescription = - stringResource( - if (layer.isVisible) { - Res.string.hide_layer - } else { - Res.string.show_layer - }, - ), - ) - } - IconButton(onClick = { onRemoveLayer(layer.id) }) { - Icon( - imageVector = MeshtasticIcons.Delete, - contentDescription = stringResource(Res.string.remove_layer), - ) - } - } - }, - ) - HorizontalDivider() - } - } - item { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Button(modifier = Modifier.fillMaxWidth(), onClick = onAddLayerClicked) { - Text(stringResource(Res.string.add_layer)) - } - Button(modifier = Modifier.fillMaxWidth(), onClick = { showAddNetworkLayerDialog = true }) { - Text(stringResource(Res.string.add_network_layer)) - } - } - } - } - - if (showAddNetworkLayerDialog) { - AddNetworkLayerDialog( - onDismiss = { showAddNetworkLayerDialog = false }, - onConfirm = { name, url -> - onAddNetworkLayer(name, url) - showAddNetworkLayerDialog = false - }, - ) - } -} - -@Composable -fun AddNetworkLayerDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) { - var name by remember { mutableStateOf("") } - var url by remember { mutableStateOf("") } - - MeshtasticDialog( - onDismiss = onDismiss, - title = stringResource(Res.string.add_network_layer), - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text(stringResource(Res.string.name)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = url, - onValueChange = { url = it }, - label = { Text(stringResource(Res.string.url)) }, - placeholder = { Text(stringResource(Res.string.network_layer_url_hint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - onConfirm = { onConfirm(name, url) }, - confirmTextRes = Res.string.save, - dismissTextRes = Res.string.cancel, - ) -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt deleted file mode 100644 index 8082e40d1..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt +++ /dev/null @@ -1,324 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.component - -import android.content.Intent -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -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.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.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.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.flow.collectLatest -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.MapViewModel -import org.meshtastic.app.map.model.CustomTileProviderConfig -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.add_custom_tile_source -import org.meshtastic.core.resources.add_local_mbtiles_file -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.delete_custom_tile_source -import org.meshtastic.core.resources.edit_custom_tile_source -import org.meshtastic.core.resources.local_mbtiles_file -import org.meshtastic.core.resources.manage_custom_tile_sources -import org.meshtastic.core.resources.name -import org.meshtastic.core.resources.name_cannot_be_empty -import org.meshtastic.core.resources.no_custom_tile_sources_found -import org.meshtastic.core.resources.provider_name_exists -import org.meshtastic.core.resources.save -import org.meshtastic.core.resources.url_cannot_be_empty -import org.meshtastic.core.resources.url_must_contain_placeholders -import org.meshtastic.core.resources.url_template -import org.meshtastic.core.resources.url_template_hint -import org.meshtastic.core.ui.component.MeshtasticDialog -import org.meshtastic.core.ui.icon.Delete -import org.meshtastic.core.ui.icon.Edit -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.util.showToast - -@Suppress("LongMethod") -@Composable -fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) { - val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle() - var editingConfig by remember { mutableStateOf(null) } - var showEditDialog by remember { mutableStateOf(false) } - val context = LocalContext.current - - val mbtilesPickerLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == android.app.Activity.RESULT_OK) { - result.data?.data?.let { uri -> - val fileName = uri.getFileName(context) - val baseName = fileName.substringBeforeLast('.') - mapViewModel.addCustomTileProvider( - name = baseName, - urlTemplate = "", // Empty for local - localUri = uri.toString(), - ) - } - } - } - - LaunchedEffect(Unit) { mapViewModel.errorFlow.collectLatest { errorMessage -> context.showToast(errorMessage) } } - - 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(Res.string.manage_custom_tile_sources), - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(16.dp), - ) - HorizontalDivider() - } - - if (customTileProviders.isEmpty()) { - item { - Text( - text = stringResource(Res.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 = { - if (config.isLocal) { - Text( - stringResource(Res.string.local_mbtiles_file), - style = MaterialTheme.typography.bodySmall, - ) - } else { - Text(config.urlTemplate, style = MaterialTheme.typography.bodySmall) - } - }, - trailingContent = { - Row { - IconButton( - onClick = { - editingConfig = config - showEditDialog = true - }, - ) { - Icon( - MeshtasticIcons.Edit, - contentDescription = stringResource(Res.string.edit_custom_tile_source), - ) - } - IconButton(onClick = { mapViewModel.removeCustomTileProvider(config.id) }) { - Icon( - MeshtasticIcons.Delete, - contentDescription = stringResource(Res.string.delete_custom_tile_source), - ) - } - } - }, - ) - HorizontalDivider() - } - } - - item { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { - editingConfig = null - showEditDialog = true - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(Res.string.add_custom_tile_source)) - } - - Button( - onClick = { - val intent = - Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" - } - mbtilesPickerLauncher.launch(intent) - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(Res.string.add_local_mbtiles_file)) - } - } - } - } -} - -@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(null) } - var urlError by remember { mutableStateOf(null) } - val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle() - - val emptyNameError = stringResource(Res.string.name_cannot_be_empty) - val providerNameExistsError = stringResource(Res.string.provider_name_exists) - val urlCannotBeEmptyError = stringResource(Res.string.url_cannot_be_empty) - val urlMustContainPlaceholdersError = stringResource(Res.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) - } - } - - MeshtasticDialog( - onDismiss = onDismiss, - title = - if (config == null) { - stringResource(Res.string.add_custom_tile_source) - } else { - stringResource(Res.string.edit_custom_tile_source) - }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = name, - onValueChange = { - name = it - nameError = null - }, - label = { Text(stringResource(Res.string.name)) }, - isError = nameError != null, - supportingText = { nameError?.let { Text(it) } }, - singleLine = true, - ) - OutlinedTextField( - value = url, - onValueChange = { - url = it - urlError = null - }, - label = { Text(stringResource(Res.string.url_template)) }, - isError = urlError != null, - supportingText = { - if (urlError != null) { - Text(urlError!!) - } else { - Text(stringResource(Res.string.url_template_hint)) - } - }, - singleLine = false, - maxLines = 2, - ) - } - }, - onConfirm = { validateAndSave() }, - confirmTextRes = Res.string.save, - dismissTextRes = Res.string.cancel, - ) -} - -private fun validateName( - name: String, - providers: List, - 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 - } - -private fun android.net.Uri.getFileName(context: android.content.Context): String { - var name = this.lastPathSegment ?: "mbtiles_file" - 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 -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt deleted file mode 100644 index 18eb0ac83..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt +++ /dev/null @@ -1,375 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.component - -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.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.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 kotlinx.datetime.LocalDate -import kotlinx.datetime.Month -import kotlinx.datetime.atTime -import kotlinx.datetime.number -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.systemTimeZone -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.date -import org.meshtastic.core.resources.delete -import org.meshtastic.core.resources.description -import org.meshtastic.core.resources.expires -import org.meshtastic.core.resources.locked -import org.meshtastic.core.resources.name -import org.meshtastic.core.resources.send -import org.meshtastic.core.resources.time -import org.meshtastic.core.resources.waypoint_edit -import org.meshtastic.core.resources.waypoint_new -import org.meshtastic.core.ui.emoji.EmojiPickerDialog -import org.meshtastic.core.ui.icon.CalendarMonth -import org.meshtastic.core.ui.icon.Lock -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.proto.Waypoint -import kotlin.time.Duration.Companion.hours - -@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) Res.string.waypoint_new else Res.string.waypoint_edit - val defaultEmoji = 0x1F4CD // 📍 Round Pushpin - val currentEmojiCodepoint = if ((waypointInput.icon ?: 0) == 0) defaultEmoji else waypointInput.icon!! - var showEmojiPickerView by remember { mutableStateOf(false) } - - val context = LocalContext.current - val tz = systemTimeZone - - // 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) != 0 && waypointInput.expire != Int.MAX_VALUE) - } - - val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) } - val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) } - dateFormat.timeZone = java.util.TimeZone.getDefault() - timeFormat.timeZone = java.util.TimeZone.getDefault() - - LaunchedEffect(waypointInput.expire, isExpiryEnabled) { - val expireValue = waypointInput.expire ?: 0 - if (isExpiryEnabled) { - if (expireValue != 0 && expireValue != Int.MAX_VALUE) { - val instant = kotlin.time.Instant.fromEpochSeconds(expireValue.toLong()) - val date = java.util.Date(instant.toEpochMilliseconds()) - selectedDateString = dateFormat.format(date) - selectedTimeString = timeFormat.format(date) - } else { // If enabled but not set, default to 8 hours from now - val futureInstant = kotlin.time.Clock.System.now() + 8.hours - val date = java.util.Date(futureInstant.toEpochMilliseconds()) - selectedDateString = dateFormat.format(date) - selectedTimeString = timeFormat.format(date) - waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.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(Res.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(Res.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 = MeshtasticIcons.Lock, - contentDescription = stringResource(Res.string.locked), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(Res.string.locked)) - } - Switch( - checked = (waypointInput.locked_to ?: 0) != 0, - onCheckedChange = { waypointInput = waypointInput.copy(locked_to = 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 = MeshtasticIcons.CalendarMonth, - contentDescription = stringResource(Res.string.expires), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(Res.string.expires)) - } - Switch( - checked = isExpiryEnabled, - onCheckedChange = { checked -> - isExpiryEnabled = checked - if (checked) { - val expireValue = waypointInput.expire ?: 0 - // Default to 8 hours from now if not already set - if (expireValue == 0 || expireValue == Int.MAX_VALUE) { - val futureInstant = kotlin.time.Clock.System.now() + 8.hours - waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt()) - } - } else { - waypointInput = waypointInput.copy(expire = Int.MAX_VALUE) - } - }, - ) - } - - if (isExpiryEnabled) { - val currentInstant = - (waypointInput.expire ?: 0).let { - if (it != 0 && it != Int.MAX_VALUE) { - kotlin.time.Instant.fromEpochSeconds(it.toLong()) - } else { - kotlin.time.Clock.System.now() + 8.hours - } - } - val ldt = currentInstant.toLocalDateTime(tz) - - val datePickerDialog = - DatePickerDialog( - context, - { _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int -> - val currentLdt = - (waypointInput.expire ?: 0) - .let { - if (it != 0 && it != Int.MAX_VALUE) { - kotlin.time.Instant.fromEpochSeconds(it.toLong()) - } else { - kotlin.time.Clock.System.now() + 8.hours - } - } - .toLocalDateTime(tz) - - val newLdt = - LocalDate( - year = selectedYear, - month = Month(selectedMonth + 1), - day = selectedDay, - ) - .atTime( - hour = currentLdt.hour, - minute = currentLdt.minute, - second = currentLdt.second, - nanosecond = currentLdt.nanosecond, - ) - waypointInput = - waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt()) - }, - ldt.year, - ldt.month.number - 1, - ldt.day, - ) - - val timePickerDialog = - TimePickerDialog( - context, - { _: TimePicker, selectedHour: Int, selectedMinute: Int -> - val currentLdt = - (waypointInput.expire ?: 0) - .let { - if (it != 0 && it != Int.MAX_VALUE) { - kotlin.time.Instant.fromEpochSeconds(it.toLong()) - } else { - kotlin.time.Clock.System.now() + 8.hours - } - } - .toLocalDateTime(tz) - - val newLdt = - LocalDate( - year = currentLdt.year, - month = currentLdt.month, - day = currentLdt.day, - ) - .atTime( - hour = selectedHour, - minute = selectedMinute, - second = currentLdt.second, - nanosecond = currentLdt.nanosecond, - ) - waypointInput = - waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt()) - }, - ldt.hour, - ldt.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(Res.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(Res.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(Res.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(Res.string.cancel)) - } - Button( - onClick = { onSendClicked(waypointInput) }, - enabled = (waypointInput.name ?: "").isNotBlank(), - ) { - Text(stringResource(Res.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)) - } - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt deleted file mode 100644 index d8e29120e..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.component - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.MapViewModel -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.last_heard_filter_label -import org.meshtastic.core.resources.only_favorites -import org.meshtastic.core.resources.show_precision_circle -import org.meshtastic.core.resources.show_waypoints -import org.meshtastic.core.ui.icon.Favorite -import org.meshtastic.core.ui.icon.Lens -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.PinDrop -import org.meshtastic.feature.map.LastHeardFilter -import kotlin.math.roundToInt - -@Composable -internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) { - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { - DropdownMenuItem( - text = { Text(stringResource(Res.string.only_favorites)) }, - onClick = { mapViewModel.toggleOnlyFavorites() }, - leadingIcon = { - Icon( - imageVector = MeshtasticIcons.Favorite, - contentDescription = stringResource(Res.string.only_favorites), - ) - }, - trailingIcon = { - Checkbox( - checked = mapFilterState.onlyFavorites, - onCheckedChange = { mapViewModel.toggleOnlyFavorites() }, - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.show_waypoints)) }, - onClick = { mapViewModel.toggleShowWaypointsOnMap() }, - leadingIcon = { - Icon( - imageVector = MeshtasticIcons.PinDrop, - contentDescription = stringResource(Res.string.show_waypoints), - ) - }, - trailingIcon = { - Checkbox( - checked = mapFilterState.showWaypoints, - onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() }, - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.show_precision_circle)) }, - onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - leadingIcon = { - Icon( - imageVector = MeshtasticIcons.Lens, - contentDescription = stringResource(Res.string.show_precision_circle), - ) - }, - trailingIcon = { - Checkbox( - checked = mapFilterState.showPrecisionCircle, - onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - ) - }, - ) - HorizontalDivider() - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - val filterOptions = LastHeardFilter.entries - val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter) - var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } - - Text( - text = - stringResource( - Res.string.last_heard_filter_label, - stringResource(mapFilterState.lastHeardFilter.label), - ), - style = MaterialTheme.typography.labelLarge, - ) - Slider( - value = sliderPosition, - onValueChange = { sliderPosition = it }, - onValueChangeFinished = { - val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1) - mapViewModel.setLastHeardFilter(filterOptions[newIndex]) - }, - valueRange = 0f..(filterOptions.size - 1).toFloat(), - steps = filterOptions.size - 2, - ) - } - } -} - -@Composable -internal fun NodeMapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) { - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - val filterOptions = LastHeardFilter.entries - val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardTrackFilter) - var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } - - Text( - text = - stringResource( - Res.string.last_heard_filter_label, - stringResource(mapFilterState.lastHeardTrackFilter.label), - ), - style = MaterialTheme.typography.labelLarge, - ) - Slider( - value = sliderPosition, - onValueChange = { sliderPosition = it }, - onValueChangeFinished = { - val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1) - mapViewModel.setLastHeardTrackFilter(filterOptions[newIndex]) - }, - valueRange = 0f..(filterOptions.size - 1).toFloat(), - steps = filterOptions.size - 2, - ) - } - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt deleted file mode 100644 index ad4bd58bb..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.component - -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.lifecycle.compose.collectAsStateWithLifecycle -import com.google.maps.android.compose.MapType -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.MapViewModel -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.manage_custom_tile_sources -import org.meshtastic.core.resources.map_type_hybrid -import org.meshtastic.core.resources.map_type_normal -import org.meshtastic.core.resources.map_type_satellite -import org.meshtastic.core.resources.map_type_terrain -import org.meshtastic.core.resources.selected_map_type -import org.meshtastic.core.ui.icon.Check -import org.meshtastic.core.ui.icon.MeshtasticIcons - -@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(Res.string.map_type_normal) to MapType.NORMAL, - stringResource(Res.string.map_type_satellite) to MapType.SATELLITE, - stringResource(Res.string.map_type_terrain) to MapType.TERRAIN, - stringResource(Res.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( - MeshtasticIcons.Check, - contentDescription = stringResource(Res.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( - MeshtasticIcons.Check, - contentDescription = stringResource(Res.string.selected_map_type), - ) - } - } else { - null - }, - ) - } - } - HorizontalDivider() - DropdownMenuItem( - text = { Text(stringResource(Res.string.manage_custom_tile_sources)) }, - onClick = { - onManageCustomTileProvidersClicked() - onDismissRequest() - }, - ) - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt deleted file mode 100644 index 32e250475..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.component - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalView -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.setViewTreeLifecycleOwner -import androidx.savedstate.compose.LocalSavedStateRegistryOwner -import androidx.savedstate.findViewTreeSavedStateRegistryOwner -import androidx.savedstate.setViewTreeSavedStateRegistryOwner -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 com.google.maps.android.compose.clustering.ClusteringMarkerProperties -import org.meshtastic.app.map.model.NodeClusterItem -import org.meshtastic.feature.map.BaseMapViewModel - -@OptIn(MapsComposeExperimentalApi::class) -@Suppress("NestedBlockDepth") -@Composable -fun NodeClusterMarkers( - nodeClusterItems: List, - mapFilterState: BaseMapViewModel.MapFilterState, - navigateToNodeDetails: (Int) -> Unit, - onClusterClick: (Cluster) -> Boolean, -) { - val view = LocalView.current - val lifecycleOwner = LocalLifecycleOwner.current - val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current - - // Workaround for https://github.com/googlemaps/android-maps-compose/issues/858 - // The maps clustering library creates an internal ComposeView to snapshot markers. - // If that view is not attached to the hierarchy (which it often isn't during rendering), - // it fails to find the Lifecycle and SavedState owners. We propagate them to the root view - // so the internal snapshot view can find them when walking up the tree. - LaunchedEffect(view, lifecycleOwner, savedStateRegistryOwner) { - val root = view.rootView - if (root.findViewTreeLifecycleOwner() == null) { - root.setViewTreeLifecycleOwner(lifecycleOwner) - } - if (root.findViewTreeSavedStateRegistryOwner() == null) { - root.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) - } - } - - Clustering( - items = nodeClusterItems, - onClusterClick = onClusterClick, - onClusterItemInfoWindowClick = { item -> - navigateToNodeDetails(item.node.num) - false - }, - clusterItemContent = { clusterItem -> PulsingNodeChip(node = clusterItem.node) }, - onClusterManager = { clusterManager -> - (clusterManager.renderer as DefaultClusterRenderer).minClusterSize = 10 - }, - clusterItemDecoration = { clusterItem -> - if (mapFilterState.showPrecisionCircle) { - 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 = 0f, - ) - } - } - } - // Use the item's own priority-based zIndex (5f for My Node/Favorites, 4f for others) - ClusteringMarkerProperties(zIndex = clusterItem.getZIndex()) - }, - ) -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt deleted file mode 100644 index 5403b8c11..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.component - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.Node -import org.meshtastic.core.ui.component.NodeChip - -@Composable -fun PulsingNodeChip(node: Node, modifier: Modifier = Modifier) { - val animatedProgress = remember { Animatable(0f) } - - LaunchedEffect(node) { - if ((nowSeconds - node.lastHeard) <= 5) { - launch { - animatedProgress.snapTo(0f) - animatedProgress.animateTo( - targetValue = 1f, - animationSpec = tween(durationMillis = 1000, easing = LinearEasing), - ) - } - } - } - - Box( - modifier = - modifier.drawWithContent { - drawContent() - if (animatedProgress.value > 0 && animatedProgress.value < 1f) { - val alpha = (1f - animatedProgress.value) * 0.3f - drawRoundRect( - size = size, - cornerRadius = CornerRadius(8.dp.toPx()), - color = Color.White.copy(alpha = alpha), - ) - } - }, - ) { - NodeChip(node = node) - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt deleted file mode 100644 index 61cdab9f1..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.component - -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.google.android.gms.maps.model.LatLng -import com.google.maps.android.compose.MapsComposeExperimentalApi -import com.google.maps.android.compose.Marker -import com.google.maps.android.compose.rememberComposeBitmapDescriptor -import com.google.maps.android.compose.rememberUpdatedMarkerState -import kotlinx.coroutines.launch -import org.meshtastic.app.map.convertIntToEmoji -import org.meshtastic.core.model.util.GeoConstants.DEG_D -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.locked -import org.meshtastic.core.ui.util.showToast -import org.meshtastic.feature.map.BaseMapViewModel -import org.meshtastic.proto.Waypoint - -@OptIn(MapsComposeExperimentalApi::class) -@Composable -fun WaypointMarkers( - displayableWaypoints: List, - mapFilterState: BaseMapViewModel.MapFilterState, - myNodeNum: Int, - isConnected: Boolean, - onEditWaypointRequest: (Waypoint) -> Unit, - selectedWaypointId: Int? = null, -) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - if (mapFilterState.showWaypoints) { - displayableWaypoints.forEach { waypoint -> - val markerState = - rememberUpdatedMarkerState( - position = LatLng((waypoint.latitude_i ?: 0) * DEG_D, (waypoint.longitude_i ?: 0) * DEG_D), - ) - - LaunchedEffect(selectedWaypointId) { - if (selectedWaypointId == waypoint.id) { - markerState.showInfoWindow() - } - } - - val iconCodePoint = if ((waypoint.icon ?: 0) == 0) PUSHPIN else waypoint.icon!! - val emojiText = convertIntToEmoji(iconCodePoint) - val icon = - rememberComposeBitmapDescriptor(iconCodePoint) { - Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp)) - } - - Marker( - state = markerState, - icon = icon, - title = (waypoint.name ?: "").replace('\n', ' ').replace('\b', ' '), - snippet = (waypoint.description ?: "").replace('\n', ' ').replace('\b', ' '), - visible = true, - onInfoWindowClick = { - if ((waypoint.locked_to ?: 0) == 0 || waypoint.locked_to == myNodeNum || !isConnected) { - onEditWaypointRequest(waypoint) - } else { - scope.launch { context.showToast(Res.string.locked) } - } - }, - ) - } - } -} - -private const val PUSHPIN = 0x1F4CD // Unicode for Round Pushpin diff --git a/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt b/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt deleted file mode 100644 index a28b3b6c1..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.model - -import kotlinx.serialization.Serializable -import kotlin.uuid.Uuid - -@Serializable -data class CustomTileProviderConfig( - val id: String = Uuid.random().toString(), - val name: String, - val urlTemplate: String, - val localUri: String? = null, -) { - val isLocal: Boolean - get() = localUri != null -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt b/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt deleted file mode 100644 index 4adb7d97d..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.model - -class CustomTileSource { - - companion object { - fun getTileSource(index: Int) { - index - } - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt b/app/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt deleted file mode 100644 index 943d2c826..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.model - -import com.google.android.gms.maps.model.LatLng -import com.google.maps.android.clustering.ClusterItem -import org.meshtastic.core.model.Node - -data class NodeClusterItem( - val node: Node, - val nodePosition: LatLng, - val nodeTitle: String, - val nodeSnippet: String, - val myNodeNum: Int? = null, -) : ClusterItem { - override fun getPosition(): LatLng = nodePosition - - override fun getTitle(): String = nodeTitle - - override fun getSnippet(): String = nodeSnippet - - override fun getZIndex(): Float = when { - node.num == myNodeNum -> 5.0f // My node is always highest - node.isFavorite -> 5.0f // Favorites are equally high priority - else -> 4.0f - } - - 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.precision_bits ?: 0] - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt deleted file mode 100644 index fa17fedbf..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.node - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.meshtastic.app.map.GoogleMapMode -import org.meshtastic.app.map.MapView -import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.feature.map.node.NodeMapViewModel - -@Composable -fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { - val node by nodeMapViewModel.node.collectAsStateWithLifecycle() - val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() - - Scaffold( - topBar = { - MainAppBar( - title = node?.user?.long_name ?: "", - ourNode = null, - showNodeChip = false, - canNavigateUp = true, - onNavigateUp = onNavigateUp, - actions = {}, - onClickChip = {}, - ) - }, - ) { paddingValues -> - MapView( - modifier = Modifier.fillMaxSize().padding(paddingValues), - mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions), - ) - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt deleted file mode 100644 index 2f7244b97..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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.app.map.node - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.app.map.GoogleMapMode -import org.meshtastic.app.map.MapView -import org.meshtastic.feature.map.node.NodeMapViewModel -import org.meshtastic.proto.Position - -/** - * Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to a - * [org.meshtastic.core.model.Node] via [NodeMapViewModel] and delegates to [MapView] in [GoogleMapMode.NodeTrack] mode, - * which provides the full shared map infrastructure (location tracking, tile providers, controls overlay with track - * filter). - * - * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. - */ -@Composable -fun NodeTrackMap( - destNum: Int, - positions: List, - modifier: Modifier = Modifier, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, -) { - val vm = koinViewModel() - vm.setDestNum(destNum) - val focusedNode by vm.node.collectAsStateWithLifecycle() - MapView( - modifier = modifier, - mode = - GoogleMapMode.NodeTrack( - focusedNode = focusedNode, - positions = positions, - selectedPositionTime = selectedPositionTime, - onPositionSelected = onPositionSelected, - ), - ) -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt deleted file mode 100644 index e33fb1f8c..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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.app.map.prefs.di - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.SharedPreferencesMigration -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStoreFile -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import org.koin.core.annotation.ComponentScan -import org.koin.core.annotation.Module -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single - -@Module -@ComponentScan("org.meshtastic.app.map") -class GoogleMapsKoinModule { - - @Single - @Named("GoogleMapsDataStore") - fun provideGoogleMapsDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), - produceFile = { context.preferencesDataStoreFile("google_maps_ds") }, - ) -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt b/app/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt deleted file mode 100644 index 6cf6091b1..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.prefs.map - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.doublePreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.floatPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.core.stringSetPreferencesKey -import com.google.maps.android.compose.MapType -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.di.CoroutineDispatchers - -/** Interface for prefs specific to Google Maps. For general map prefs, see MapPrefs. */ -interface GoogleMapsPrefs { - val selectedGoogleMapType: StateFlow - - fun setSelectedGoogleMapType(value: String?) - - val selectedCustomTileUrl: StateFlow - - fun setSelectedCustomTileUrl(value: String?) - - val hiddenLayerUrls: StateFlow> - - fun setHiddenLayerUrls(value: Set) - - val cameraTargetLat: StateFlow - - fun setCameraTargetLat(value: Double) - - val cameraTargetLng: StateFlow - - fun setCameraTargetLng(value: Double) - - val cameraZoom: StateFlow - - fun setCameraZoom(value: Float) - - val cameraTilt: StateFlow - - fun setCameraTilt(value: Float) - - val cameraBearing: StateFlow - - fun setCameraBearing(value: Float) - - val networkMapLayers: StateFlow> - - fun setNetworkMapLayers(value: Set) -} - -@Single -class GoogleMapsPrefsImpl( - @Named("GoogleMapsDataStore") private val dataStore: DataStore, - dispatchers: CoroutineDispatchers, -) : GoogleMapsPrefs { - private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) - - override val selectedGoogleMapType: StateFlow = - dataStore.data - .map { it[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] ?: MapType.NORMAL.name } - .stateIn(scope, SharingStarted.Eagerly, MapType.NORMAL.name) - - override fun setSelectedGoogleMapType(value: String?) { - scope.launch { - dataStore.edit { prefs -> - if (value == null) { - prefs.remove(KEY_SELECTED_GOOGLE_MAP_TYPE_PREF) - } else { - prefs[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] = value - } - } - } - } - - override val selectedCustomTileUrl: StateFlow = - dataStore.data.map { it[KEY_SELECTED_CUSTOM_TILE_URL_PREF] }.stateIn(scope, SharingStarted.Eagerly, null) - - override fun setSelectedCustomTileUrl(value: String?) { - scope.launch { - dataStore.edit { prefs -> - if (value == null) { - prefs.remove(KEY_SELECTED_CUSTOM_TILE_URL_PREF) - } else { - prefs[KEY_SELECTED_CUSTOM_TILE_URL_PREF] = value - } - } - } - } - - override val hiddenLayerUrls: StateFlow> = - dataStore.data - .map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() } - .stateIn(scope, SharingStarted.Eagerly, emptySet()) - - override fun setHiddenLayerUrls(value: Set) { - scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS_PREF] = value } } - } - - override val cameraTargetLat: StateFlow = - dataStore.data - .map { - try { - it[KEY_CAMERA_TARGET_LAT_PREF] ?: 0.0 - } catch (_: ClassCastException) { - it[floatPreferencesKey(KEY_CAMERA_TARGET_LAT_PREF.name)]?.toDouble() ?: 0.0 - } - } - .stateIn(scope, SharingStarted.Eagerly, 0.0) - - override fun setCameraTargetLat(value: Double) { - scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LAT_PREF] = value } } - } - - override val cameraTargetLng: StateFlow = - dataStore.data - .map { - try { - it[KEY_CAMERA_TARGET_LNG_PREF] ?: 0.0 - } catch (_: ClassCastException) { - it[floatPreferencesKey(KEY_CAMERA_TARGET_LNG_PREF.name)]?.toDouble() ?: 0.0 - } - } - .stateIn(scope, SharingStarted.Eagerly, 0.0) - - override fun setCameraTargetLng(value: Double) { - scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LNG_PREF] = value } } - } - - override val cameraZoom: StateFlow = - dataStore.data.map { it[KEY_CAMERA_ZOOM_PREF] ?: 7f }.stateIn(scope, SharingStarted.Eagerly, 7f) - - override fun setCameraZoom(value: Float) { - scope.launch { dataStore.edit { it[KEY_CAMERA_ZOOM_PREF] = value } } - } - - override val cameraTilt: StateFlow = - dataStore.data.map { it[KEY_CAMERA_TILT_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f) - - override fun setCameraTilt(value: Float) { - scope.launch { dataStore.edit { it[KEY_CAMERA_TILT_PREF] = value } } - } - - override val cameraBearing: StateFlow = - dataStore.data.map { it[KEY_CAMERA_BEARING_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f) - - override fun setCameraBearing(value: Float) { - scope.launch { dataStore.edit { it[KEY_CAMERA_BEARING_PREF] = value } } - } - - override val networkMapLayers: StateFlow> = - dataStore.data - .map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() } - .stateIn(scope, SharingStarted.Eagerly, emptySet()) - - override fun setNetworkMapLayers(value: Set) { - scope.launch { dataStore.edit { it[KEY_NETWORK_MAP_LAYERS_PREF] = value } } - } - - companion object { - val KEY_SELECTED_GOOGLE_MAP_TYPE_PREF = stringPreferencesKey("selected_google_map_type") - val KEY_SELECTED_CUSTOM_TILE_URL_PREF = stringPreferencesKey("selected_custom_tile_url") - val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPreferencesKey("hidden_layer_urls") - val KEY_CAMERA_TARGET_LAT_PREF = doublePreferencesKey("camera_target_lat") - val KEY_CAMERA_TARGET_LNG_PREF = doublePreferencesKey("camera_target_lng") - val KEY_CAMERA_ZOOM_PREF = floatPreferencesKey("camera_zoom") - val KEY_CAMERA_TILT_PREF = floatPreferencesKey("camera_tilt") - val KEY_CAMERA_BEARING_PREF = floatPreferencesKey("camera_bearing") - val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPreferencesKey("network_map_layers") - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt b/app/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt deleted file mode 100644 index 6840cb17d..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright (c) 2025-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.app.map.repository - -import co.touchlab.kermit.Logger -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 org.koin.core.annotation.Single -import org.meshtastic.app.map.model.CustomTileProviderConfig -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.repository.MapTileProviderPrefs - -interface CustomTileProviderRepository { - fun getCustomTileProviders(): Flow> - - suspend fun addCustomTileProvider(config: CustomTileProviderConfig) - - suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) - - suspend fun deleteCustomTileProvider(configId: String) - - suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? -} - -@Single -class CustomTileProviderRepositoryImpl( - private val json: Json, - private val dispatchers: CoroutineDispatchers, - private val mapTileProviderPrefs: MapTileProviderPrefs, -) : CustomTileProviderRepository { - - private val customTileProvidersStateFlow = MutableStateFlow>(emptyList()) - - init { - loadDataFromPrefs() - } - - override fun getCustomTileProviders(): Flow> = - 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 } - - private fun loadDataFromPrefs() { - val jsonString = mapTileProviderPrefs.customTileProviders.value - if (jsonString != null) { - try { - customTileProvidersStateFlow.value = json.decodeFromString>(jsonString) - } catch (e: SerializationException) { - Logger.e(e) { "Error deserializing tile providers" } - customTileProvidersStateFlow.value = emptyList() - } - } else { - customTileProvidersStateFlow.value = emptyList() - } - } - - private suspend fun saveDataToPrefs(providers: List) { - withContext(dispatchers.io) { - try { - val jsonString = json.encodeToString(providers) - mapTileProviderPrefs.setCustomTileProviders(jsonString) - } catch (e: SerializationException) { - Logger.e(e) { "Error serializing tile providers" } - } - } - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt deleted file mode 100644 index d725537c8..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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.app.map.traceroute - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import org.meshtastic.app.map.GoogleMapMode -import org.meshtastic.app.map.MapView -import org.meshtastic.core.model.TracerouteOverlay -import org.meshtastic.proto.Position - -/** - * Flavor-unified entry point for the embeddable traceroute map. Delegates to [MapView] in [GoogleMapMode.Traceroute] - * mode, which provides the full shared map infrastructure (location tracking, tile providers, controls overlay). - */ -@Composable -fun TracerouteMap( - tracerouteOverlay: TracerouteOverlay?, - tracerouteNodePositions: Map, - onMappableCountChanged: (shown: Int, total: Int) -> Unit, - modifier: Modifier = Modifier, -) { - MapView( - modifier = modifier, - mode = - GoogleMapMode.Traceroute( - overlay = tracerouteOverlay, - nodePositions = tracerouteNodePositions, - onMappableCountChanged = onMappableCountChanged, - ), - ) -} diff --git a/app/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/app/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt deleted file mode 100644 index c86e7a78c..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) 2025-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.app.node.component - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.key -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import com.google.android.gms.maps.model.CameraPosition -import com.google.android.gms.maps.model.LatLng -import com.google.maps.android.compose.Circle -import com.google.maps.android.compose.ComposeMapColorScheme -import com.google.maps.android.compose.GoogleMap -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.rememberCameraPositionState -import com.google.maps.android.compose.rememberUpdatedMarkerState -import org.meshtastic.core.model.Node -import org.meshtastic.core.ui.component.NodeChip -import org.meshtastic.core.ui.component.precisionBitsToMeters - -private const val DEFAULT_ZOOM = 15f - -@OptIn(MapsComposeExperimentalApi::class) -@Composable -fun InlineMap(node: Node, modifier: Modifier = Modifier) { - val dark = isSystemInDarkTheme() - val mapColorScheme = - when (dark) { - true -> ComposeMapColorScheme.DARK - else -> ComposeMapColorScheme.LIGHT - } - key(node.num) { - val location = LatLng(node.latitude, node.longitude) - val cameraState = rememberCameraPositionState { - position = CameraPosition.fromLatLngZoom(location, DEFAULT_ZOOM) - } - - GoogleMap( - mapColorScheme = mapColorScheme, - modifier = modifier, - uiSettings = - MapUiSettings( - zoomControlsEnabled = true, - mapToolbarEnabled = false, - compassEnabled = false, - myLocationButtonEnabled = false, - rotationGesturesEnabled = false, - scrollGesturesEnabled = false, - tiltGesturesEnabled = false, - zoomGesturesEnabled = false, - ), - cameraPositionState = cameraState, - ) { - val precisionMeters = precisionBitsToMeters(node.position.precision_bits ?: 0) - val latLng = LatLng(node.latitude, node.longitude) - if (precisionMeters > 0) { - Circle( - center = latLng, - radius = precisionMeters, - fillColor = Color(node.colors.second).copy(alpha = 0.2f), - strokeColor = Color(node.colors.second), - strokeWidth = 2f, - ) - } - MarkerComposable(state = rememberUpdatedMarkerState(position = latLng)) { NodeChip(node = node) } - } - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt b/app/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt deleted file mode 100644 index 992edf588..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025-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.app.node.metrics - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.ui.Alignment -import androidx.compose.ui.unit.dp -import org.meshtastic.core.ui.util.TracerouteMapOverlayInsets - -fun getTracerouteMapOverlayInsets(): TracerouteMapOverlayInsets = TracerouteMapOverlayInsets( - overlayAlignment = Alignment.BottomCenter, - overlayPadding = PaddingValues(bottom = 16.dp), - contentHorizontalAlignment = Alignment.CenterHorizontally, -) diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 03549c0b3..c27db822b 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -52,9 +52,6 @@ import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.meshtastic.app.intro.AnalyticsIntro -import org.meshtastic.app.map.getMapViewProvider -import org.meshtastic.app.node.component.InlineMap -import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets import org.meshtastic.app.ui.MainScreen import org.meshtastic.core.barcode.rememberBarcodeScanner import org.meshtastic.core.common.util.toMeshtasticUri @@ -68,25 +65,12 @@ import org.meshtastic.core.ui.theme.MODE_DYNAMIC import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported -import org.meshtastic.core.ui.util.LocalInlineMapProvider -import org.meshtastic.core.ui.util.LocalMapMainScreenProvider -import org.meshtastic.core.ui.util.LocalMapViewProvider import org.meshtastic.core.ui.util.LocalNfcScannerProvider import org.meshtastic.core.ui.util.LocalNfcScannerSupported -import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider -import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider -import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider -import org.meshtastic.core.ui.util.LocalTracerouteMapProvider -import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider import org.meshtastic.core.ui.util.showToast import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.feature.intro.AppIntroductionScreen import org.meshtastic.feature.intro.IntroViewModel -import org.meshtastic.feature.map.MapScreen -import org.meshtastic.feature.map.SharedMapViewModel -import org.meshtastic.feature.map.node.NodeMapViewModel -import org.meshtastic.feature.node.metrics.MetricsViewModel -import org.meshtastic.feature.node.metrics.TracerouteMapScreen class MainActivity : ComponentActivity() { private val model: UIViewModel by viewModel() @@ -172,56 +156,6 @@ class MainActivity : ComponentActivity() { LocalBarcodeScannerSupported provides true, LocalNfcScannerSupported provides true, LocalAnalyticsIntroProvider provides { AnalyticsIntro() }, - LocalMapViewProvider provides getMapViewProvider(), - LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) }, - LocalNodeTrackMapProvider provides - { destNum, positions, modifier, selectedPositionTime, onPositionSelected -> - org.meshtastic.app.map.node.NodeTrackMap( - destNum, - positions, - modifier, - selectedPositionTime, - onPositionSelected, - ) - }, - LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(), - LocalTracerouteMapProvider provides - { overlay, nodePositions, onMappableCountChanged, modifier -> - org.meshtastic.app.map.traceroute.TracerouteMap( - tracerouteOverlay = overlay, - tracerouteNodePositions = nodePositions, - onMappableCountChanged = onMappableCountChanged, - modifier = modifier, - ) - }, - LocalNodeMapScreenProvider provides - { destNum, onNavigateUp -> - val vm = koinViewModel() - vm.setDestNum(destNum) - org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp) - }, - LocalTracerouteMapScreenProvider provides - { destNum, requestId, logUuid, onNavigateUp -> - val metricsViewModel = koinViewModel { parametersOf(destNum) } - metricsViewModel.setNodeId(destNum) - - TracerouteMapScreen( - metricsViewModel = metricsViewModel, - requestId = requestId, - logUuid = logUuid, - onNavigateUp = onNavigateUp, - ) - }, - LocalMapMainScreenProvider provides - { onClickNodeChip, navigateToNodeDetails, waypointId -> - val viewModel = koinViewModel() - MapScreen( - viewModel = viewModel, - onClickNodeChip = onClickNodeChip, - navigateToNodeDetails = navigateToNodeDetails, - waypointId = waypointId, - ) - }, content = content, ) } diff --git a/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt b/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt index 7b140cca8..64228d590 100644 --- a/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt +++ b/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt @@ -28,8 +28,8 @@ import kotlinx.coroutines.CoroutineDispatcher import org.koin.test.verify.definition import org.koin.test.verify.injectedParameters import org.koin.test.verify.verify -import org.meshtastic.app.map.MapViewModel import org.meshtastic.core.model.util.NodeIdLookup +import org.meshtastic.feature.map.MapViewModel import org.meshtastic.feature.node.metrics.MetricsViewModel import kotlin.test.Test diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapCameraPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapCameraPrefsImpl.kt new file mode 100644 index 000000000..b7f27080b --- /dev/null +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapCameraPrefsImpl.kt @@ -0,0 +1,126 @@ +/* + * 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.core.prefs.map + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.doublePreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.repository.MapCameraPrefs + +@Single +class MapCameraPrefsImpl( + @Named("MapDataStore") private val dataStore: DataStore, + dispatchers: CoroutineDispatchers, +) : MapCameraPrefs { + private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) + + override val cameraLat: StateFlow = + dataStore.data.map { it[KEY_CAMERA_LAT] ?: DEFAULT_LAT }.stateIn(scope, SharingStarted.Eagerly, DEFAULT_LAT) + + override fun setCameraLat(value: Double) { + scope.launch { dataStore.edit { it[KEY_CAMERA_LAT] = value } } + } + + override val cameraLng: StateFlow = + dataStore.data.map { it[KEY_CAMERA_LNG] ?: DEFAULT_LNG }.stateIn(scope, SharingStarted.Eagerly, DEFAULT_LNG) + + override fun setCameraLng(value: Double) { + scope.launch { dataStore.edit { it[KEY_CAMERA_LNG] = value } } + } + + override val cameraZoom: StateFlow = + dataStore.data.map { it[KEY_CAMERA_ZOOM] ?: DEFAULT_ZOOM }.stateIn(scope, SharingStarted.Eagerly, DEFAULT_ZOOM) + + override fun setCameraZoom(value: Float) { + scope.launch { dataStore.edit { it[KEY_CAMERA_ZOOM] = value } } + } + + override val cameraTilt: StateFlow = + dataStore.data.map { it[KEY_CAMERA_TILT] ?: DEFAULT_TILT }.stateIn(scope, SharingStarted.Eagerly, DEFAULT_TILT) + + override fun setCameraTilt(value: Float) { + scope.launch { dataStore.edit { it[KEY_CAMERA_TILT] = value } } + } + + override val cameraBearing: StateFlow = + dataStore.data + .map { it[KEY_CAMERA_BEARING] ?: DEFAULT_BEARING } + .stateIn(scope, SharingStarted.Eagerly, DEFAULT_BEARING) + + override fun setCameraBearing(value: Float) { + scope.launch { dataStore.edit { it[KEY_CAMERA_BEARING] = value } } + } + + override val selectedStyleUri: StateFlow = + dataStore.data + .map { it[KEY_SELECTED_STYLE_URI] ?: DEFAULT_STYLE_URI } + .stateIn(scope, SharingStarted.Eagerly, DEFAULT_STYLE_URI) + + override fun setSelectedStyleUri(value: String) { + scope.launch { dataStore.edit { it[KEY_SELECTED_STYLE_URI] = value } } + } + + override val hiddenLayerUrls: StateFlow> = + dataStore.data + .map { it[KEY_HIDDEN_LAYER_URLS] ?: emptySet() } + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + override fun setHiddenLayerUrls(value: Set) { + scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS] = value } } + } + + override val networkMapLayers: StateFlow> = + dataStore.data + .map { it[KEY_NETWORK_MAP_LAYERS] ?: emptySet() } + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + override fun setNetworkMapLayers(value: Set) { + scope.launch { dataStore.edit { it[KEY_NETWORK_MAP_LAYERS] = value } } + } + + companion object { + private const val DEFAULT_LAT = 0.0 + private const val DEFAULT_LNG = 0.0 + private const val DEFAULT_ZOOM = 7f + private const val DEFAULT_TILT = 0f + private const val DEFAULT_BEARING = 0f + private const val DEFAULT_STYLE_URI = "" + + val KEY_CAMERA_LAT = doublePreferencesKey("map_camera_lat") + val KEY_CAMERA_LNG = doublePreferencesKey("map_camera_lng") + val KEY_CAMERA_ZOOM = floatPreferencesKey("map_camera_zoom") + val KEY_CAMERA_TILT = floatPreferencesKey("map_camera_tilt") + val KEY_CAMERA_BEARING = floatPreferencesKey("map_camera_bearing") + val KEY_SELECTED_STYLE_URI = stringPreferencesKey("map_selected_style_uri") + val KEY_HIDDEN_LAYER_URLS = stringSetPreferencesKey("map_hidden_layer_urls") + val KEY_NETWORK_MAP_LAYERS = stringSetPreferencesKey("map_network_layers") + } +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index f5203e3c1..d12b6894f 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -167,6 +167,41 @@ interface MapPrefs { fun setLastHeardTrackFilter(seconds: Long) } +/** Reactive interface for map camera position persistence. */ +interface MapCameraPrefs { + val cameraLat: StateFlow + + fun setCameraLat(value: Double) + + val cameraLng: StateFlow + + fun setCameraLng(value: Double) + + val cameraZoom: StateFlow + + fun setCameraZoom(value: Float) + + val cameraTilt: StateFlow + + fun setCameraTilt(value: Float) + + val cameraBearing: StateFlow + + fun setCameraBearing(value: Float) + + val selectedStyleUri: StateFlow + + fun setSelectedStyleUri(value: String) + + val hiddenLayerUrls: StateFlow> + + fun setHiddenLayerUrls(value: Set) + + val networkMapLayers: StateFlow> + + fun setNetworkMapLayers(value: Set) +} + /** Reactive interface for map consent. */ interface MapConsentPrefs { fun shouldReportLocation(nodeNum: Int?): StateFlow @@ -234,6 +269,7 @@ interface AppPreferences { val emoji: CustomEmojiPrefs val ui: UiPrefs val map: MapPrefs + val mapCamera: MapCameraPrefs val mapConsent: MapConsentPrefs val mapTileProvider: MapTileProviderPrefs val radio: RadioPrefs diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 5d7eba25a..682fe8874 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1149,6 +1149,11 @@ Configuration Wirelessly manage your device settings and channels. Map style selection + OpenStreetMap + Satellite + Terrain + Hybrid + Dark Battery: %1$d% Nodes: %1$d online / %2$d total diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt index 2b9f9918f..1a2c1b811 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt @@ -23,6 +23,7 @@ import org.meshtastic.core.repository.AppPreferences import org.meshtastic.core.repository.CustomEmojiPrefs import org.meshtastic.core.repository.FilterPrefs import org.meshtastic.core.repository.HomoglyphPrefs +import org.meshtastic.core.repository.MapCameraPrefs import org.meshtastic.core.repository.MapConsentPrefs import org.meshtastic.core.repository.MapPrefs import org.meshtastic.core.repository.MapTileProviderPrefs @@ -192,6 +193,56 @@ class FakeMapPrefs : MapPrefs { } } +class FakeMapCameraPrefs : MapCameraPrefs { + override val cameraLat = MutableStateFlow(0.0) + + override fun setCameraLat(value: Double) { + cameraLat.value = value + } + + override val cameraLng = MutableStateFlow(0.0) + + override fun setCameraLng(value: Double) { + cameraLng.value = value + } + + override val cameraZoom = MutableStateFlow(7f) + + override fun setCameraZoom(value: Float) { + cameraZoom.value = value + } + + override val cameraTilt = MutableStateFlow(0f) + + override fun setCameraTilt(value: Float) { + cameraTilt.value = value + } + + override val cameraBearing = MutableStateFlow(0f) + + override fun setCameraBearing(value: Float) { + cameraBearing.value = value + } + + override val selectedStyleUri = MutableStateFlow("") + + override fun setSelectedStyleUri(value: String) { + selectedStyleUri.value = value + } + + override val hiddenLayerUrls = MutableStateFlow(emptySet()) + + override fun setHiddenLayerUrls(value: Set) { + hiddenLayerUrls.value = value + } + + override val networkMapLayers = MutableStateFlow(emptySet()) + + override fun setNetworkMapLayers(value: Set) { + networkMapLayers.value = value + } +} + class FakeMapConsentPrefs : MapConsentPrefs { private val consent = mutableMapOf>() @@ -258,6 +309,7 @@ class FakeAppPreferences : AppPreferences { override val emoji = FakeCustomEmojiPrefs() override val ui = FakeUiPrefs() override val map = FakeMapPrefs() + override val mapCamera = FakeMapCameraPrefs() override val mapConsent = FakeMapConsentPrefs() override val mapTileProvider = FakeMapTileProviderPrefs() override val radio = FakeRadioPrefs() diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalInlineMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalInlineMapProvider.kt deleted file mode 100644 index e2a3206d1..000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalInlineMapProvider.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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.core.ui.util - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.ui.Modifier -import org.meshtastic.core.model.Node - -val LocalInlineMapProvider = compositionLocalOf<@Composable (node: Node, modifier: Modifier) -> Unit> { { _, _ -> } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt deleted file mode 100644 index 70ed07a2b..000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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.core.ui.util - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf -import org.meshtastic.core.ui.component.PlaceholderScreen - -/** - * Provides the platform-specific Map Main Screen. On Desktop or JVM targets where native maps aren't available yet, it - * falls back to a [PlaceholderScreen]. - */ -@Suppress("Wrapping") -val LocalMapMainScreenProvider = - compositionLocalOf< - @Composable (onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) -> Unit, - > { - { _, _, _ -> PlaceholderScreen("Map") } - } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeMapScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeMapScreenProvider.kt deleted file mode 100644 index 7e54003a5..000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeMapScreenProvider.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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.core.ui.util - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf -import org.meshtastic.core.ui.component.PlaceholderScreen - -/** - * Provides the platform-specific Map Screen for a Node (e.g. Google Maps or OSMDroid on Android). On Desktop or JVM - * targets where native maps aren't available yet, it falls back to a [PlaceholderScreen]. - */ -@Suppress("Wrapping") -val LocalNodeMapScreenProvider = - compositionLocalOf<@Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit> { - { destNum, _ -> PlaceholderScreen("Node Map ($destNum)") } - } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt deleted file mode 100644 index d0901f0f9..000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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.core.ui.util - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.ui.Modifier -import org.meshtastic.core.ui.component.PlaceholderScreen -import org.meshtastic.proto.Position - -/** - * Provides an embeddable position-track map composable that renders a polyline with markers for the given [positions]. - * Unlike [LocalNodeMapScreenProvider], this does **not** include a Scaffold or AppBar — it is designed to be embedded - * inside another screen layout (e.g. the position-log adaptive layout). - * - * Supports optional synchronized selection: - * - [selectedPositionTime]: the `Position.time` of the currently selected position (or `null` for no selection). When - * non-null, the map should visually highlight the corresponding marker and center the camera on it. - * - [onPositionSelected]: callback invoked when a position marker is tapped on the map, passing the `Position.time` so - * the host can synchronize the card list. - * - * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. - */ -@Suppress("Wrapping") -val LocalNodeTrackMapProvider = - compositionLocalOf< - @Composable ( - destNum: Int, - positions: List, - modifier: Modifier, - selectedPositionTime: Int?, - onPositionSelected: ((Int) -> Unit)?, - ) -> Unit, - > { - { _, _, _, _, _ -> PlaceholderScreen("Position Track Map") } - } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapOverlayInsetsProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapOverlayInsetsProvider.kt deleted file mode 100644 index 40b174e8d..000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapOverlayInsetsProvider.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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.core.ui.util - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.ui.Alignment -import androidx.compose.ui.unit.dp - -data class TracerouteMapOverlayInsets( - val overlayAlignment: Alignment = Alignment.BottomCenter, - val overlayPadding: PaddingValues = PaddingValues(bottom = 16.dp), - val contentHorizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, -) - -val LocalTracerouteMapOverlayInsetsProvider = compositionLocalOf { TracerouteMapOverlayInsets() } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt deleted file mode 100644 index 139992c54..000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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.core.ui.util - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.ui.Modifier -import org.meshtastic.core.model.TracerouteOverlay -import org.meshtastic.core.ui.component.PlaceholderScreen -import org.meshtastic.proto.Position - -/** - * Provides an embeddable traceroute map composable that renders node markers and forward/return offset polylines for a - * traceroute result. Unlike [LocalMapViewProvider], this does **not** include a Scaffold, AppBar, waypoints, location - * tracking, custom tiles, or any main-map features — it is designed to be embedded inside `TracerouteMapScreen`'s - * scaffold. - * - * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. - * - * Parameters: - * - `tracerouteOverlay`: The overlay with forward/return route node nums. - * - `tracerouteNodePositions`: Map of node num to position snapshots for the route nodes. - * - `onMappableCountChanged`: Callback with (shown, total) node counts. - * - `modifier`: Compose modifier for the map. - */ -@Suppress("Wrapping") -val LocalTracerouteMapProvider = - compositionLocalOf< - @Composable ( - tracerouteOverlay: TracerouteOverlay?, - tracerouteNodePositions: Map, - onMappableCountChanged: (Int, Int) -> Unit, - modifier: Modifier, - ) -> Unit, - > { - { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } - } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapScreenProvider.kt deleted file mode 100644 index 26eb02b7e..000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapScreenProvider.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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.core.ui.util - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf -import org.meshtastic.core.ui.component.PlaceholderScreen - -/** - * Provides the platform-specific Traceroute Map Screen. On Desktop or JVM targets where native maps aren't available - * yet, it falls back to a [PlaceholderScreen]. - */ -@Suppress("Wrapping") -val LocalTracerouteMapScreenProvider = - compositionLocalOf<@Composable (destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) -> Unit> { - { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } - } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt deleted file mode 100644 index 10d975f3d..000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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.core.ui.util - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.ui.Modifier - -/** - * Interface for providing a flavored MapView. This allows the map feature to be decoupled from specific map - * implementations (Google Maps vs OSMDroid). Platform implementations create their own ViewModel via Koin. - */ -interface MapViewProvider { - @Composable fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int? = null) -} - -val LocalMapViewProvider = compositionLocalOf { null } diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index db52c350a..943f990a5 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -31,6 +31,8 @@ kotlin { commonMain.dependencies { implementation(libs.jetbrains.navigation3.ui) implementation(libs.kotlinx.collections.immutable) + api(libs.maplibre.compose) + implementation(libs.maplibre.compose.material3) implementation(projects.core.data) implementation(projects.core.database) implementation(projects.core.datastore) diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt deleted file mode 100644 index 588ca198b..000000000 --- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (c) 2025-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 - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.map -import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.ui.util.LocalMapViewProvider - -@Composable -fun MapScreen( - onClickNodeChip: (Int) -> Unit, - navigateToNodeDetails: (Int) -> Unit, - modifier: Modifier = Modifier, - viewModel: SharedMapViewModel, - waypointId: Int? = null, -) { - val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle() - val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() - - @Suppress("ViewModelForwarding") - Scaffold( - modifier = modifier, - topBar = { - MainAppBar( - title = stringResource(Res.string.map), - ourNode = ourNodeInfo, - showNodeChip = ourNodeInfo != null && isConnected, - canNavigateUp = false, - onNavigateUp = {}, - actions = {}, - onClickChip = { onClickNodeChip(it.num) }, - ) - }, - ) { paddingValues -> - LocalMapViewProvider.current?.MapView( - modifier = Modifier.fillMaxSize().padding(paddingValues), - navigateToNodeDetails = navigateToNodeDetails, - waypointId = waypointId, - ) - } -} diff --git a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt b/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt deleted file mode 100644 index 0490e9410..000000000 --- a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) 2025-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 - -import android.database.sqlite.SQLiteDatabase -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import java.io.File -import kotlin.test.assertEquals - -@RunWith(RobolectricTestRunner::class) -class MBTilesProviderTest { - - @get:Rule val tempFolder = TemporaryFolder() - - @Test - fun `getTile translates y coordinate correctly to TMS`() { - val dbFile = tempFolder.newFile("test.mbtiles") - setupMockDatabase(dbFile) - - val provider = MBTilesProvider(dbFile) - - // Google Maps zoom 1, x=0, y=0 - // TMS y = (1 << 1) - 1 - 0 = 1 - provider.getTile(0, 0, 1) - - // We verify the query was correct by checking the database if we could, - // but here we just ensure it doesn't crash and returns the expected No Tile if missing. - // To truly test, we'd need to insert data. - - val db = SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE) - db.execSQL("INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (1, 0, 1, x'1234')") - db.close() - - val tile = provider.getTile(0, 0, 1) - assertEquals(256, tile?.width) - assertEquals(256, tile?.height) - // Robolectric SQLite might return different blob handling, but let's see. - } - - private fun setupMockDatabase(file: File) { - val db = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY) - db.execSQL("CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB)") - db.close() - } -} diff --git a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt deleted file mode 100644 index 7026e1fb6..000000000 --- a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright (c) 2025-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 - -import android.app.Application -import androidx.lifecycle.SavedStateHandle -import com.google.android.gms.maps.model.UrlTileProvider -import dev.mokkery.MockMode -import dev.mokkery.every -import dev.mokkery.mock -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.meshtastic.core.repository.MapPrefs -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.core.testing.FakeNodeRepository -import org.meshtastic.core.testing.FakeRadioController -import org.meshtastic.feature.map.model.CustomTileProviderConfig -import org.meshtastic.feature.map.prefs.map.GoogleMapsPrefs -import org.meshtastic.feature.map.repository.CustomTileProviderRepository -import org.robolectric.RobolectricTestRunner -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -class MapViewModelTest { - - private val application = mock(MockMode.autofill) - private val mapPrefs = mock(MockMode.autofill) - private val googleMapsPrefs = mock(MockMode.autofill) - private val nodeRepository = FakeNodeRepository() - private val packetRepository = mock(MockMode.autofill) - private val radioConfigRepository = mock(MockMode.autofill) - private val radioController = FakeRadioController() - private val customTileProviderRepository = mock(MockMode.autofill) - private val uiPrefs = mock(MockMode.autofill) - private val savedStateHandle = SavedStateHandle(mapOf("waypointId" to null)) - - private val testDispatcher = StandardTestDispatcher() - - private lateinit var viewModel: MapViewModel - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - every { mapPrefs.mapStyle } returns MutableStateFlow(0) - every { mapPrefs.showOnlyFavorites } returns MutableStateFlow(false) - every { mapPrefs.showWaypointsOnMap } returns MutableStateFlow(true) - every { mapPrefs.showPrecisionCircleOnMap } returns MutableStateFlow(true) - every { mapPrefs.lastHeardFilter } returns MutableStateFlow(0L) - every { mapPrefs.lastHeardTrackFilter } returns MutableStateFlow(0L) - - every { googleMapsPrefs.cameraTargetLat } returns MutableStateFlow(0.0) - every { googleMapsPrefs.cameraTargetLng } returns MutableStateFlow(0.0) - every { googleMapsPrefs.cameraZoom } returns MutableStateFlow(0f) - every { googleMapsPrefs.cameraTilt } returns MutableStateFlow(0f) - every { googleMapsPrefs.cameraBearing } returns MutableStateFlow(0f) - every { googleMapsPrefs.selectedCustomTileUrl } returns MutableStateFlow(null) - every { googleMapsPrefs.selectedGoogleMapType } returns MutableStateFlow(null) - every { googleMapsPrefs.hiddenLayerUrls } returns MutableStateFlow(emptySet()) - - every { customTileProviderRepository.getCustomTileProviders() } returns flowOf(emptyList()) - every { radioConfigRepository.deviceProfileFlow } returns flowOf(mock(MockMode.autofill)) - every { uiPrefs.theme } returns MutableStateFlow(1) - every { packetRepository.getWaypoints() } returns flowOf(emptyList()) - - viewModel = - MapViewModel( - application, - mapPrefs, - googleMapsPrefs, - nodeRepository, - packetRepository, - radioConfigRepository, - radioController, - customTileProviderRepository, - uiPrefs, - savedStateHandle, - ) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `getTileProvider returns UrlTileProvider for remote config`() = runTest { - val config = - CustomTileProviderConfig( - name = "OpenStreetMap", - urlTemplate = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - ) - - val provider = viewModel.getTileProvider(config) - assertTrue(provider is UrlTileProvider) - } - - @Test - fun `addNetworkMapLayer detects GeoJSON based on extension`() = runTest(testDispatcher) { - viewModel.addNetworkMapLayer("Test Layer", "https://example.com/data.geojson") - advanceUntilIdle() - - val layer = viewModel.mapLayers.value.find { it.name == "Test Layer" } - assertEquals(LayerType.GEOJSON, layer?.layerType) - } - - @Test - fun `addNetworkMapLayer defaults to KML for other extensions`() = runTest(testDispatcher) { - viewModel.addNetworkMapLayer("Test KML", "https://example.com/map.kml") - advanceUntilIdle() - - val layer = viewModel.mapLayers.value.find { it.name == "Test KML" } - assertEquals(LayerType.KML, layer?.layerType) - } - - @Test - fun `setWaypointId updates value correctly including null`() = runTest(testDispatcher) { - // Set to a valid ID - viewModel.setWaypointId(123) - assertEquals(123, viewModel.selectedWaypointId.value) - - // Set to null should clear the selection - viewModel.setWaypointId(null) - assertEquals(null, viewModel.selectedWaypointId.value) - } -} 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 new file mode 100644 index 000000000..92291d3f3 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025-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 + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.maplibre.compose.camera.rememberCameraState +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.map +import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.feature.map.component.MapControlsOverlay +import org.meshtastic.feature.map.component.MaplibreMapContent + +/** + * Main map screen composable. Uses MapLibre Compose Multiplatform to render an interactive map with mesh node markers, + * waypoints, and overlays. + * + * This replaces the previous flavor-specific Google Maps and OSMDroid implementations with a single cross-platform + * composable. + */ +@Composable +fun MapScreen( + onClickNodeChip: (Int) -> Unit, + navigateToNodeDetails: (Int) -> Unit, + modifier: Modifier = Modifier, + viewModel: MapViewModel, + waypointId: Int? = null, +) { + val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle() + val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() + val nodesWithPosition by viewModel.nodesWithPosition.collectAsStateWithLifecycle() + val waypoints by viewModel.waypoints.collectAsStateWithLifecycle() + val filterState by viewModel.mapFilterStateFlow.collectAsStateWithLifecycle() + val baseStyle by viewModel.baseStyle.collectAsStateWithLifecycle() + + LaunchedEffect(waypointId) { viewModel.setWaypointId(waypointId) } + + val cameraState = rememberCameraState(firstPosition = viewModel.initialCameraPosition) + + @Suppress("ViewModelForwarding") + Scaffold( + modifier = modifier, + topBar = { + MainAppBar( + title = stringResource(Res.string.map), + ourNode = ourNodeInfo, + showNodeChip = ourNodeInfo != null && isConnected, + canNavigateUp = false, + onNavigateUp = {}, + actions = {}, + onClickChip = { onClickNodeChip(it.num) }, + ) + }, + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + MaplibreMapContent( + nodes = nodesWithPosition, + waypoints = waypoints, + baseStyle = baseStyle, + cameraState = cameraState, + myNodeNum = viewModel.myNodeNum, + showWaypoints = filterState.showWaypoints, + showPrecisionCircle = filterState.showPrecisionCircle, + onNodeClick = { nodeNum -> navigateToNodeDetails(nodeNum) }, + onMapLongClick = { position -> + // TODO: open waypoint creation dialog at position + }, + modifier = Modifier.fillMaxSize(), + onCameraMoved = { position -> viewModel.saveCameraPosition(position) }, + ) + + MapControlsOverlay( + onToggleFilterMenu = {}, + modifier = Modifier.align(Alignment.TopEnd).padding(paddingValues), + bearing = cameraState.position.bearing.toFloat(), + onCompassClick = {}, + isLocationTrackingEnabled = false, + onToggleLocationTracking = {}, + ) + } + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt new file mode 100644 index 000000000..f05502127 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -0,0 +1,100 @@ +/* + * 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 + +import androidx.lifecycle.SavedStateHandle +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.KoinViewModel +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.style.BaseStyle +import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.MapCameraPrefs +import org.meshtastic.core.repository.MapPrefs +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed +import org.meshtastic.feature.map.model.MapStyle +import org.maplibre.spatialk.geojson.Position as GeoPosition + +/** + * Unified map ViewModel replacing the previous Google and F-Droid flavor-specific ViewModels. + * + * Manages camera state persistence, map style selection, and waypoint selection using MapLibre Compose Multiplatform + * types. All map-related state is shared across platforms. + */ +@KoinViewModel +class MapViewModel( + mapPrefs: MapPrefs, + private val mapCameraPrefs: MapCameraPrefs, + nodeRepository: NodeRepository, + packetRepository: PacketRepository, + radioController: RadioController, + savedStateHandle: SavedStateHandle, +) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) { + + /** Currently selected waypoint to focus on map. */ + private val selectedWaypointIdInternal = MutableStateFlow(savedStateHandle.get("waypointId")) + val selectedWaypointId: StateFlow = selectedWaypointIdInternal.asStateFlow() + + fun setWaypointId(id: Int?) { + selectedWaypointIdInternal.value = id + } + + /** Initial camera position restored from persistent preferences. */ + val initialCameraPosition: CameraPosition + get() = + CameraPosition( + target = + GeoPosition(longitude = mapCameraPrefs.cameraLng.value, latitude = mapCameraPrefs.cameraLat.value), + zoom = mapCameraPrefs.cameraZoom.value.toDouble(), + tilt = mapCameraPrefs.cameraTilt.value.toDouble(), + bearing = mapCameraPrefs.cameraBearing.value.toDouble(), + ) + + /** Active map base style. */ + val baseStyle: StateFlow = + mapCameraPrefs.selectedStyleUri + .map { uri -> if (uri.isBlank()) MapStyle.OpenStreetMap.toBaseStyle() else BaseStyle.Uri(uri) } + .stateInWhileSubscribed(MapStyle.OpenStreetMap.toBaseStyle()) + + /** Currently selected map style enum index. */ + val selectedMapStyle: StateFlow = + mapCameraPrefs.selectedStyleUri + .map { uri -> MapStyle.entries.find { it.styleUri == uri } ?: MapStyle.OpenStreetMap } + .stateInWhileSubscribed(MapStyle.OpenStreetMap) + + /** Persist camera position to DataStore. */ + fun saveCameraPosition(position: CameraPosition) { + mapCameraPrefs.setCameraLat(position.target.latitude) + mapCameraPrefs.setCameraLng(position.target.longitude) + mapCameraPrefs.setCameraZoom(position.zoom.toFloat()) + mapCameraPrefs.setCameraTilt(position.tilt.toFloat()) + mapCameraPrefs.setCameraBearing(position.bearing.toFloat()) + } + + /** Select a predefined map style. */ + fun selectMapStyle(style: MapStyle) { + mapCameraPrefs.setSelectedStyleUri(style.styleUri) + } + + /** Bearing for the compass in degrees. */ + val compassBearing: Float + get() = mapCameraPrefs.cameraBearing.value +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt deleted file mode 100644 index bcebdabf6..000000000 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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 - -import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.repository.MapPrefs -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.PacketRepository - -@KoinViewModel -class SharedMapViewModel( - mapPrefs: MapPrefs, - nodeRepository: NodeRepository, - packetRepository: PacketRepository, - radioController: RadioController, -) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) 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 new file mode 100644 index 000000000..7e9cfa2cd --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt @@ -0,0 +1,106 @@ +/* + * 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.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.map.GestureOptions +import org.maplibre.compose.map.MapOptions +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.precisionBitsToMeters +import org.maplibre.spatialk.geojson.Position as GeoPosition + +private const val DEFAULT_ZOOM = 15.0 +private const val COORDINATE_SCALE = 1e-7 +private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f +private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f + +/** + * A compact, non-interactive map showing a single node's position. Used in node detail screens. Replaces both the + * Google Maps and OSMDroid inline map implementations. + */ +@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 + + key(node.num) { + val cameraState = + rememberCameraState( + firstPosition = + CameraPosition(target = GeoPosition(longitude = lng, latitude = lat), zoom = DEFAULT_ZOOM), + ) + + val nodeFeature = + remember(node.num, lat, lng) { + FeatureCollection( + listOf(Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = null)), + ) + } + + MaplibreMap( + modifier = modifier, + baseStyle = BaseStyle.Uri("https://tiles.openfreemap.org/styles/liberty"), + cameraState = cameraState, + options = + MapOptions(gestureOptions = GestureOptions.AllDisabled, ornamentOptions = OrnamentOptions.AllDisabled), + ) { + val source = rememberGeoJsonSource(data = GeoJsonData.Features(nodeFeature)) + + // Node marker dot + CircleLayer( + id = "inline-node-marker", + source = source, + radius = const(8.dp), + color = const(Color(node.colors.second)), + strokeWidth = const(2.dp), + strokeColor = const(Color.White), + ) + + // Precision circle + val precisionMeters = precisionBitsToMeters(position.precision_bits ?: 0) + if (precisionMeters > 0) { + CircleLayer( + id = "inline-node-precision", + source = source, + radius = const(40.dp), // visual approximation + color = const(Color(node.colors.second).copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)), + strokeWidth = const(1.dp), + strokeColor = const(Color(node.colors.second).copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)), + ) + } + } + } +} 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 new file mode 100644 index 000000000..19e65518b --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -0,0 +1,205 @@ +/* + * 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.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.CameraState +import org.maplibre.compose.expressions.dsl.asString +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.dsl.feature +import org.maplibre.compose.expressions.dsl.not +import org.maplibre.compose.expressions.dsl.offset +import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.layers.SymbolLayer +import org.maplibre.compose.map.MaplibreMap +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.GeoJsonOptions +import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.compose.style.BaseStyle +import org.maplibre.compose.util.ClickResult +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Node +import org.meshtastic.feature.map.util.nodesToFeatureCollection +import org.meshtastic.feature.map.util.waypointsToFeatureCollection +import org.maplibre.spatialk.geojson.Position as GeoPosition + +private val NodeMarkerColor = Color(0xFF6750A4) +private const val CLUSTER_RADIUS = 50 +private const val CLUSTER_MIN_POINTS = 10 +private const val PRECISION_CIRCLE_FILL_ALPHA = 0.1f +private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f +private const val CLUSTER_OPACITY = 0.85f + +/** + * Main map content composable using MapLibre Compose Multiplatform. + * + * Renders nodes as clustered markers, waypoints, and optional overlays (position tracks, traceroute routes). Replaces + * both the Google Maps and OSMDroid implementations with a single cross-platform composable. + */ +@Composable +fun MaplibreMapContent( + nodes: List, + waypoints: Map, + baseStyle: BaseStyle, + cameraState: CameraState, + myNodeNum: Int?, + showWaypoints: Boolean, + showPrecisionCircle: Boolean, + onNodeClick: (Int) -> Unit, + onMapLongClick: (GeoPosition) -> Unit, + modifier: Modifier = Modifier, + onCameraMoved: (CameraPosition) -> Unit = {}, +) { + MaplibreMap( + modifier = modifier, + baseStyle = baseStyle, + cameraState = cameraState, + onMapLongClick = { position, _ -> + onMapLongClick(position) + ClickResult.Consume + }, + onFrame = {}, + ) { + // --- Node markers with clustering --- + NodeMarkerLayers( + nodes = nodes, + myNodeNum = myNodeNum, + showPrecisionCircle = showPrecisionCircle, + onNodeClick = onNodeClick, + ) + + // --- Waypoint markers --- + if (showWaypoints) { + WaypointMarkerLayers(waypoints = waypoints) + } + } + + // Persist camera position when it stops moving + LaunchedEffect(cameraState.isCameraMoving) { + if (!cameraState.isCameraMoving) { + onCameraMoved(cameraState.position) + } + } +} + +/** Node markers rendered as clustered circles and symbols using GeoJSON source. */ +@Composable +private fun NodeMarkerLayers( + nodes: List, + myNodeNum: Int?, + showPrecisionCircle: Boolean, + onNodeClick: (Int) -> Unit, +) { + val featureCollection = remember(nodes, myNodeNum) { nodesToFeatureCollection(nodes, myNodeNum) } + + val nodesSource = + rememberGeoJsonSource( + data = GeoJsonData.Features(featureCollection), + options = + GeoJsonOptions(cluster = true, clusterRadius = CLUSTER_RADIUS, clusterMinPoints = CLUSTER_MIN_POINTS), + ) + + // Cluster circles + CircleLayer( + id = "node-clusters", + source = nodesSource, + filter = feature.has("cluster"), + radius = const(20.dp), + color = const(NodeMarkerColor), // Material primary + opacity = const(CLUSTER_OPACITY), + strokeWidth = const(2.dp), + strokeColor = const(Color.White), + ) + + // Cluster count labels + SymbolLayer( + id = "node-cluster-count", + source = nodesSource, + filter = feature.has("cluster"), + textField = feature["point_count"].asString(), + textColor = const(Color.White), + textSize = const(1.2f.em), + ) + + // Individual node markers + CircleLayer( + id = "node-markers", + source = nodesSource, + filter = !feature.has("cluster"), + radius = const(8.dp), + color = const(NodeMarkerColor), + strokeWidth = const(2.dp), + strokeColor = const(Color.White), + onClick = { features -> + val nodeNum = features.firstOrNull()?.properties?.get("node_num")?.toString()?.toIntOrNull() + if (nodeNum != null) { + onNodeClick(nodeNum) + ClickResult.Consume + } else { + ClickResult.Pass + } + }, + ) + + // Precision circles + if (showPrecisionCircle) { + CircleLayer( + id = "node-precision", + source = nodesSource, + filter = !feature.has("cluster"), + radius = const(40.dp), // TODO: scale by precision_meters and zoom + color = const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)), + strokeWidth = const(1.dp), + strokeColor = const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)), + ) + } +} + +/** Waypoint markers rendered as symbol layer with emoji icons. */ +@Composable +private fun WaypointMarkerLayers(waypoints: Map) { + val featureCollection = remember(waypoints) { waypointsToFeatureCollection(waypoints) } + + val waypointSource = rememberGeoJsonSource(data = GeoJsonData.Features(featureCollection)) + + // Waypoint emoji labels + SymbolLayer( + id = "waypoint-markers", + source = waypointSource, + textField = feature["emoji"].asString(), + textSize = const(2f.em), + textAllowOverlap = const(true), + iconAllowOverlap = const(true), + ) + + // Waypoint name labels below emoji + SymbolLayer( + id = "waypoint-labels", + source = waypointSource, + textField = feature["name"].asString(), + textSize = const(1.em), + textOffset = offset(0f.em, 2f.em), + textColor = const(Color.DarkGray), + ) +} 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 new file mode 100644 index 000000000..94341d419 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt @@ -0,0 +1,103 @@ +/* + * 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.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.layers.LineLayer +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.GeoJsonOptions +import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.compose.util.ClickResult +import org.meshtastic.feature.map.util.positionsToLineString +import org.meshtastic.feature.map.util.positionsToPointFeatures + +private val TrackColor = Color(0xFF2196F3) +private val SelectedPointColor = Color(0xFFF44336) +private const val TRACK_OPACITY = 0.8f +private const val SELECTED_OPACITY = 0.9f + +/** + * Renders a position history track as a line with marker points. Replaces the Google Maps Polyline + MarkerComposable + * and OSMDroid Polyline overlay implementations. + */ +@Composable +fun NodeTrackLayers( + positions: List, + selectedPositionTime: Int? = null, + onPositionSelected: ((Int) -> Unit)? = null, +) { + if (positions.size < 2) return + + // Line track source + val lineFeatureCollection = remember(positions) { positionsToLineString(positions) } + + val lineSource = + rememberGeoJsonSource( + data = GeoJsonData.Features(lineFeatureCollection), + options = GeoJsonOptions(lineMetrics = true), + ) + + // Track line with gradient + LineLayer( + id = "node-track-line", + source = lineSource, + width = const(3.dp), + color = const(TrackColor), // Blue + opacity = const(TRACK_OPACITY), + ) + + // Position marker points + val pointFeatureCollection = remember(positions) { positionsToPointFeatures(positions) } + + val pointsSource = rememberGeoJsonSource(data = GeoJsonData.Features(pointFeatureCollection)) + + CircleLayer( + id = "node-track-points", + source = pointsSource, + radius = const(5.dp), + color = const(TrackColor), + strokeWidth = const(1.dp), + strokeColor = const(Color.White), + onClick = { features -> + val time = features.firstOrNull()?.properties?.get("time")?.toString()?.toIntOrNull() + if (time != null && onPositionSelected != null) { + onPositionSelected(time) + ClickResult.Consume + } else { + ClickResult.Pass + } + }, + ) + + // Highlight selected position + if (selectedPositionTime != null) { + CircleLayer( + id = "node-track-selected", + source = pointsSource, + radius = const(10.dp), + color = const(SelectedPointColor), // Red + strokeWidth = const(2.dp), + strokeColor = const(Color.White), + opacity = const(SELECTED_OPACITY), + ) + } +} 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 new file mode 100644 index 000000000..9c7e1220d --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt @@ -0,0 +1,72 @@ +/* + * 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.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.map.MaplibreMap +import org.meshtastic.feature.map.model.MapStyle +import org.meshtastic.proto.Position +import org.maplibre.spatialk.geojson.Position as GeoPosition + +private const val DEFAULT_TRACK_ZOOM = 13.0 +private const val COORDINATE_SCALE = 1e-7 + +/** + * Embeddable position-track map showing a polyline with markers for the given positions. + * + * Supports synchronized selection: [selectedPositionTime] highlights the corresponding marker and [onPositionSelected] + * is called when a marker is tapped, passing the `Position.time` for the host screen to synchronize its card list. + * + * Replaces both the Google Maps and OSMDroid flavor-specific NodeTrackMap implementations. + */ +@Composable +fun NodeTrackMap( + positions: List, + modifier: Modifier = Modifier, + selectedPositionTime: Int? = null, + onPositionSelected: ((Int) -> Unit)? = null, +) { + val center = + remember(positions) { + positions.firstOrNull()?.let { 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 + } + } + + val cameraState = + rememberCameraState( + firstPosition = + CameraPosition( + target = center ?: GeoPosition(longitude = 0.0, latitude = 0.0), + zoom = DEFAULT_TRACK_ZOOM, + ), + ) + + MaplibreMap(modifier = modifier, baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), cameraState = cameraState) { + NodeTrackLayers( + positions = positions, + selectedPositionTime = selectedPositionTime, + onPositionSelected = onPositionSelected, + ) + } +} 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 new file mode 100644 index 000000000..d87c21602 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt @@ -0,0 +1,193 @@ +/* + * 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.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.maplibre.compose.expressions.dsl.asString +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.dsl.feature +import org.maplibre.compose.expressions.dsl.offset +import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.layers.LineLayer +import org.maplibre.compose.layers.SymbolLayer +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.spatialk.geojson.Feature +import org.maplibre.spatialk.geojson.FeatureCollection +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.maplibre.spatialk.geojson.Position as GeoPosition + +private val ForwardRouteColor = Color(0xFF4CAF50) +private val ReturnRouteColor = Color(0xFFF44336) +private val HopMarkerColor = Color(0xFF9C27B0) +private const val COORDINATE_SCALE = 1e-7 +private const val HEX_RADIX = 16 +private const val ROUTE_OPACITY = 0.8f + +/** + * Renders traceroute forward and return routes with hop markers. Replaces the Google Maps and OSMDroid traceroute + * polyline implementations. + */ +@Composable +fun TracerouteLayers( + overlay: TracerouteOverlay?, + nodePositions: Map, + nodes: Map, + onMappableCountChanged: (shown: Int, total: Int) -> Unit, +) { + if (overlay == null) return + + // Build route line features + val routeData = remember(overlay, nodePositions) { buildTracerouteGeoJson(overlay, nodePositions, nodes) } + + // Report mappable count + val mappableCount = routeData.hopFeatures.features.size + val totalCount = overlay.forwardRoute.size + overlay.returnRoute.size + onMappableCountChanged(mappableCount, totalCount) + + // Forward route line + if (routeData.forwardLine.features.isNotEmpty()) { + val forwardSource = rememberGeoJsonSource(data = GeoJsonData.Features(routeData.forwardLine)) + LineLayer( + id = "traceroute-forward", + source = forwardSource, + width = const(3.dp), + color = const(ForwardRouteColor), // Green + opacity = const(ROUTE_OPACITY), + ) + } + + // Return route line (dashed) + if (routeData.returnLine.features.isNotEmpty()) { + val returnSource = rememberGeoJsonSource(data = GeoJsonData.Features(routeData.returnLine)) + LineLayer( + id = "traceroute-return", + source = returnSource, + width = const(3.dp), + color = const(ReturnRouteColor), // Red + opacity = const(ROUTE_OPACITY), + dasharray = const(listOf(2f, 1f)), + ) + } + + // Hop markers + if (routeData.hopFeatures.features.isNotEmpty()) { + val hopsSource = rememberGeoJsonSource(data = GeoJsonData.Features(routeData.hopFeatures)) + CircleLayer( + id = "traceroute-hops", + source = hopsSource, + radius = const(8.dp), + color = const(HopMarkerColor), // Purple + strokeWidth = const(2.dp), + strokeColor = const(Color.White), + ) + SymbolLayer( + id = "traceroute-hop-labels", + source = hopsSource, + textField = feature["short_name"].asString(), + textSize = const(1.em), + textOffset = offset(0f.em, -2f.em), + textColor = const(Color.DarkGray), + ) + } +} + +private data class TracerouteGeoJsonData( + val forwardLine: FeatureCollection, + val returnLine: FeatureCollection, + val hopFeatures: FeatureCollection, +) + +private fun buildTracerouteGeoJson( + overlay: TracerouteOverlay, + nodePositions: Map, + nodes: Map, +): 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) + } + + // Build forward route line + val forwardCoords = overlay.forwardRoute.mapNotNull { nodeToGeoPosition(it) } + val forwardLine = + if (forwardCoords.size >= 2) { + val feature = + Feature( + geometry = LineString(forwardCoords), + properties = buildJsonObject { put("direction", "forward") }, + ) + @Suppress("UNCHECKED_CAST") + FeatureCollection(listOf(feature)) as FeatureCollection + } else { + @Suppress("UNCHECKED_CAST") + FeatureCollection(emptyList>()) as FeatureCollection + } + + // Build return route line + val returnCoords = overlay.returnRoute.mapNotNull { nodeToGeoPosition(it) } + val returnLine = + if (returnCoords.size >= 2) { + val feature = + Feature( + geometry = LineString(returnCoords), + properties = buildJsonObject { put("direction", "return") }, + ) + @Suppress("UNCHECKED_CAST") + FeatureCollection(listOf(feature)) as FeatureCollection + } else { + @Suppress("UNCHECKED_CAST") + FeatureCollection(emptyList>()) as FeatureCollection + } + + // Build hop marker points + val allNodeNums = overlay.relatedNodeNums + + val hopFeatures = + allNodeNums.mapNotNull { nodeNum -> + val geoPos = nodeToGeoPosition(nodeNum) ?: return@mapNotNull null + val node = nodes[nodeNum] + Feature( + geometry = Point(geoPos), + properties = + buildJsonObject { + put("node_num", nodeNum) + put("short_name", node?.user?.short_name ?: nodeNum.toUInt().toString(HEX_RADIX)) + put("long_name", node?.user?.long_name ?: "Unknown") + }, + ) + } + + @Suppress("UNCHECKED_CAST") + return TracerouteGeoJsonData( + forwardLine = forwardLine, + returnLine = returnLine, + hopFeatures = FeatureCollection(hopFeatures) as FeatureCollection, + ) +} 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 new file mode 100644 index 000000000..e198397c4 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt @@ -0,0 +1,75 @@ +/* + * 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.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.map.MaplibreMap +import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.feature.map.model.MapStyle +import org.meshtastic.proto.Position +import org.maplibre.spatialk.geojson.Position as GeoPosition + +private const val DEFAULT_TRACEROUTE_ZOOM = 10.0 +private const val COORDINATE_SCALE = 1e-7 + +/** + * Embeddable traceroute map showing forward/return route polylines with hop markers. + * + * This composable is designed to be embedded inside a parent scaffold (e.g. TracerouteMapScreen). It does NOT include + * its own Scaffold or AppBar. + * + * Replaces both the Google Maps and OSMDroid flavor-specific TracerouteMap implementations. + */ +@Composable +fun TracerouteMap( + tracerouteOverlay: TracerouteOverlay?, + tracerouteNodePositions: Map, + onMappableCountChanged: (shown: Int, total: Int) -> Unit, + modifier: Modifier = Modifier, +) { + // Center the camera on the first node with a known position. + val center = + remember(tracerouteNodePositions) { + tracerouteNodePositions.values.firstOrNull()?.let { 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 + } + } + + val cameraState = + rememberCameraState( + firstPosition = + CameraPosition( + target = center ?: GeoPosition(longitude = 0.0, latitude = 0.0), + zoom = DEFAULT_TRACEROUTE_ZOOM, + ), + ) + + MaplibreMap(modifier = modifier, baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), cameraState = cameraState) { + TracerouteLayers( + overlay = tracerouteOverlay, + nodePositions = tracerouteNodePositions, + nodes = emptyMap(), // Node lookups for short names are best-effort + onMappableCountChanged = onMappableCountChanged, + ) + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt new file mode 100644 index 000000000..21f063433 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt @@ -0,0 +1,52 @@ +/* + * 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.jetbrains.compose.resources.StringResource +import org.maplibre.compose.style.BaseStyle +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.map_style_dark +import org.meshtastic.core.resources.map_style_hybrid +import org.meshtastic.core.resources.map_style_osm +import org.meshtastic.core.resources.map_style_satellite +import org.meshtastic.core.resources.map_style_terrain + +/** + * Predefined map tile styles available in the app. + * + * Uses free tile sources that do not require API keys. Custom XYZ tile URLs and offline sources can be configured + * separately via [MapLayerItem]. + */ +enum class MapStyle(val label: StringResource, val styleUri: String) { + /** OpenStreetMap default tiles via OpenFreeMap Liberty style. */ + OpenStreetMap(label = Res.string.map_style_osm, styleUri = "https://tiles.openfreemap.org/styles/liberty"), + + /** Satellite imagery — uses OpenFreeMap with a raster overlay switcher. */ + Satellite(label = Res.string.map_style_satellite, styleUri = "https://tiles.openfreemap.org/styles/liberty"), + + /** Terrain style. */ + Terrain(label = Res.string.map_style_terrain, styleUri = "https://tiles.openfreemap.org/styles/liberty"), + + /** Satellite + labels hybrid. */ + Hybrid(label = Res.string.map_style_hybrid, styleUri = "https://tiles.openfreemap.org/styles/liberty"), + + /** Dark mode style. */ + Dark(label = Res.string.map_style_dark, styleUri = "https://tiles.openfreemap.org/styles/bright"), + ; + + fun toBaseStyle(): BaseStyle = BaseStyle.Uri(styleUri) +} 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 2c0b5e7b8..ee786764c 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 @@ -19,16 +19,20 @@ package org.meshtastic.feature.map.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey +import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.navigation.MapRoute import org.meshtastic.core.navigation.NodesRoute +import org.meshtastic.feature.map.MapScreen +import org.meshtastic.feature.map.MapViewModel fun EntryProviderScope.mapGraph(backStack: NavBackStack) { entry { args -> - val mapScreen = org.meshtastic.core.ui.util.LocalMapMainScreenProvider.current - mapScreen( - { backStack.add(NodesRoute.NodeDetail(it)) }, // onClickNodeChip - { backStack.add(NodesRoute.NodeDetail(it)) }, // navigateToNodeDetails - args.waypointId, + val viewModel = koinViewModel() + MapScreen( + viewModel = viewModel, + onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, + navigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) }, + waypointId = args.waypointId, ) } } diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt similarity index 59% rename from app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt rename to feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt index b7795180f..6bae9a23c 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * 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 @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.app.map.node +package org.meshtastic.feature.map.node import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -23,18 +23,28 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.map import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.feature.map.node.NodeMapViewModel +import org.meshtastic.feature.map.component.NodeTrackMap +/** + * Full-screen map showing a single node's position history. + * + * Includes a Scaffold with AppBar showing the node's long name. Replaces both the Google Maps and OSMDroid + * flavor-specific NodeMapScreen implementations. + */ @Composable -fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { - val node by nodeMapViewModel.node.collectAsStateWithLifecycle() - val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() +fun NodeMapScreen(viewModel: NodeMapViewModel, onNavigateUp: () -> Unit, modifier: Modifier = Modifier) { + val node by viewModel.node.collectAsStateWithLifecycle() + val positions by viewModel.positionLogs.collectAsStateWithLifecycle() Scaffold( + modifier = modifier, topBar = { MainAppBar( - title = node?.user?.long_name ?: "", + title = node?.user?.long_name ?: stringResource(Res.string.map), ourNode = null, showNodeChip = false, canNavigateUp = true, @@ -44,11 +54,6 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) ) }, ) { paddingValues -> - NodeTrackOsmMap( - positions = positions, - applicationId = nodeMapViewModel.applicationId, - mapStyleId = nodeMapViewModel.mapStyleId, - modifier = Modifier.fillMaxSize().padding(paddingValues), - ) + NodeTrackMap(positions = positions, modifier = Modifier.fillMaxSize().padding(paddingValues)) } } 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 new file mode 100644 index 000000000..a28c76627 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt @@ -0,0 +1,178 @@ +/* + * 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.util + +import kotlinx.serialization.json.JsonObject +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.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 + +/** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */ +private const val COORDINATE_SCALE = 1e-7 + +private const val MIN_PRECISION_BITS = 10 +private const val MAX_PRECISION_BITS = 19 + +/** Convert a list of nodes to a GeoJSON [FeatureCollection] for map rendering. */ +fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): FeatureCollection { + 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 colors = node.colors + val props = buildJsonObject { + put("node_num", node.num) + put("short_name", node.user.short_name) + put("long_name", node.user.long_name) + put("last_heard", node.lastHeard) + put("is_favorite", node.isFavorite) + put("is_my_node", node.num == myNodeNum) + put("hops_away", node.hopsAway) + put("via_mqtt", node.viaMqtt) + put("snr", node.snr.toDouble()) + put("rssi", node.rssi) + put("foreground_color", colors.first) + put("background_color", colors.second) + put("has_precision", (pos.precision_bits ?: 0) in MIN_PRECISION_BITS..MAX_PRECISION_BITS) + put("precision_meters", precisionBitsToMeters(pos.precision_bits ?: 0)) + } + + Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props) + } + + @Suppress("UNCHECKED_CAST") + return FeatureCollection(features) as FeatureCollection +} + +/** Convert waypoints to a GeoJSON [FeatureCollection]. */ +fun waypointsToFeatureCollection(waypoints: Map): FeatureCollection { + 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 emoji = if (waypoint.icon != 0) convertIntToEmoji(waypoint.icon) else PIN_EMOJI + + val props = buildJsonObject { + put("waypoint_id", waypoint.id) + put("name", waypoint.name) + put("description", waypoint.description) + put("emoji", emoji) + put("icon", waypoint.icon) + put("locked_to", waypoint.locked_to) + put("expire", waypoint.expire) + } + + Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props) + } + + @Suppress("UNCHECKED_CAST") + return FeatureCollection(features) as FeatureCollection +} + +/** 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) + } + + if (coords.size < 2) return FeatureCollection(emptyList()) + + val props = buildJsonObject { put("point_count", coords.size) } + + val feature = Feature(geometry = LineString(coords), properties = props) + + @Suppress("UNCHECKED_CAST") + return FeatureCollection(listOf(feature)) as FeatureCollection +} + +/** 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 props = buildJsonObject { + put("time", pos.time ?: 0) + put("altitude", pos.altitude ?: 0) + put("ground_speed", pos.ground_speed ?: 0) + put("sats_in_view", pos.sats_in_view ?: 0) + } + + Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props) + } + + @Suppress("UNCHECKED_CAST") + return FeatureCollection(features) as FeatureCollection +} + +/** Approximate meters of positional uncertainty from precision_bits (10-19). */ +@Suppress("MagicNumber") +fun precisionBitsToMeters(precisionBits: Int): Double = when (precisionBits) { + 10 -> 5886.0 + 11 -> 2944.0 + 12 -> 1472.0 + 13 -> 736.0 + 14 -> 368.0 + 15 -> 184.0 + 16 -> 92.0 + 17 -> 46.0 + 18 -> 23.0 + 19 -> 11.5 + else -> 0.0 +} + +private const val PIN_EMOJI = "\uD83D\uDCCD" +private const val BMP_MAX = 0xFFFF +private const val SUPPLEMENTARY_OFFSET = 0x10000 +private const val HALF_SHIFT = 10 +private const val HIGH_SURROGATE_BASE = 0xD800 +private const val LOW_SURROGATE_BASE = 0xDC00 +private const val SURROGATE_MASK = 0x3FF + +/** Convert a Unicode code point integer to its emoji string representation. */ +internal fun convertIntToEmoji(codePoint: Int): String = try { + if (codePoint <= BMP_MAX) { + codePoint.toChar().toString() + } else { + val offset = codePoint - SUPPLEMENTARY_OFFSET + val high = (offset shr HALF_SHIFT) + HIGH_SURROGATE_BASE + val low = (offset and SURROGATE_MASK) + LOW_SURROGATE_BASE + buildString { + append(high.toChar()) + append(low.toChar()) + } + } +} catch (_: Exception) { + PIN_EMOJI +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt new file mode 100644 index 000000000..7981ab1df --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt @@ -0,0 +1,184 @@ +/* + * 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 + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.mock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.maplibre.compose.style.BaseStyle +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.testing.FakeMapCameraPrefs +import org.meshtastic.core.testing.FakeMapPrefs +import org.meshtastic.core.testing.FakeNodeRepository +import org.meshtastic.core.testing.FakeRadioController +import org.meshtastic.feature.map.model.MapStyle +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class MapViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var viewModel: MapViewModel + private lateinit var mapCameraPrefs: FakeMapCameraPrefs + private lateinit var mapPrefs: FakeMapPrefs + private val packetRepository: PacketRepository = mock() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + mapCameraPrefs = FakeMapCameraPrefs() + mapPrefs = FakeMapPrefs() + every { packetRepository.getWaypoints() } returns MutableStateFlow(emptyList()) + viewModel = createViewModel() + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel(savedStateHandle: SavedStateHandle = SavedStateHandle()): MapViewModel = MapViewModel( + mapPrefs = mapPrefs, + mapCameraPrefs = mapCameraPrefs, + nodeRepository = FakeNodeRepository(), + packetRepository = packetRepository, + radioController = FakeRadioController(), + savedStateHandle = savedStateHandle, + ) + + @Test + fun selectedWaypointIdDefaultsToNull() { + assertNull(viewModel.selectedWaypointId.value) + } + + @Test + fun selectedWaypointIdRestoredFromSavedState() { + val vm = createViewModel(SavedStateHandle(mapOf("waypointId" to 42))) + assertEquals(42, vm.selectedWaypointId.value) + } + + @Test + fun setWaypointIdUpdatesState() { + viewModel.setWaypointId(7) + assertEquals(7, viewModel.selectedWaypointId.value) + + viewModel.setWaypointId(null) + assertNull(viewModel.selectedWaypointId.value) + } + + @Test + fun initialCameraPositionReflectsPrefs() { + mapCameraPrefs.setCameraLat(51.5) + mapCameraPrefs.setCameraLng(-0.1) + mapCameraPrefs.setCameraZoom(12f) + mapCameraPrefs.setCameraTilt(30f) + mapCameraPrefs.setCameraBearing(45f) + + val vm = createViewModel() + val pos = vm.initialCameraPosition + + assertEquals(51.5, pos.target.latitude) + assertEquals(-0.1, pos.target.longitude) + assertEquals(12.0, pos.zoom) + assertEquals(30.0, pos.tilt) + assertEquals(45.0, pos.bearing) + } + + @Test + fun saveCameraPositionPersistsToPrefs() { + val cameraPosition = + org.maplibre.compose.camera.CameraPosition( + target = org.maplibre.spatialk.geojson.Position(longitude = -122.4, latitude = 37.8), + zoom = 15.0, + tilt = 20.0, + bearing = 90.0, + ) + + viewModel.saveCameraPosition(cameraPosition) + + assertEquals(37.8, mapCameraPrefs.cameraLat.value) + assertEquals(-122.4, mapCameraPrefs.cameraLng.value) + assertEquals(15f, mapCameraPrefs.cameraZoom.value) + assertEquals(20f, mapCameraPrefs.cameraTilt.value) + assertEquals(90f, mapCameraPrefs.cameraBearing.value) + } + + @Test + fun baseStyleDefaultsToOpenStreetMap() = runTest(testDispatcher) { + viewModel.baseStyle.test { + val style = awaitItem() + assertEquals(BaseStyle.Uri(MapStyle.OpenStreetMap.styleUri), style) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun selectMapStyleUpdatesBaseStyleAndSelectedMapStyle() = runTest(testDispatcher) { + viewModel.selectedMapStyle.test { + assertEquals(MapStyle.OpenStreetMap, awaitItem()) + + viewModel.selectMapStyle(MapStyle.Dark) + assertEquals(MapStyle.Dark, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun baseStyleEmitsUriOnStyleChange() = runTest(testDispatcher) { + viewModel.baseStyle.test { + // Initial style + awaitItem() + + viewModel.selectMapStyle(MapStyle.Dark) + val darkStyle = awaitItem() + assertEquals(BaseStyle.Uri(MapStyle.Dark.styleUri), darkStyle) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun compassBearingReflectsPrefs() { + mapCameraPrefs.setCameraBearing(180f) + val vm = createViewModel() + assertEquals(180f, vm.compassBearing) + } + + @Test + fun blankStyleUriFallsBackToOpenStreetMap() = runTest(testDispatcher) { + // selectedStyleUri defaults to "" in FakeMapCameraPrefs + viewModel.baseStyle.test { + val style = awaitItem() + assertEquals(BaseStyle.Uri(MapStyle.OpenStreetMap.styleUri), style) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt index 0ab017f7b..951510174 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt @@ -43,7 +43,7 @@ import org.meshtastic.core.resources.open_compass import org.meshtastic.core.ui.icon.Compass import org.meshtastic.core.ui.icon.Distance import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.util.LocalInlineMapProvider +import org.meshtastic.feature.map.component.InlineMap import org.meshtastic.feature.node.model.NodeDetailAction import org.meshtastic.proto.Config @@ -85,7 +85,7 @@ internal fun PositionInlineContent( private fun PositionMap(node: Node, distance: String?) { Box(modifier = Modifier.padding(vertical = 4.dp)) { Surface(shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxWidth().height(MAP_HEIGHT_DP.dp)) { - LocalInlineMapProvider.current(node, Modifier.fillMaxSize()) + InlineMap(node, Modifier.fillMaxSize()) } if (distance != null && distance.isNotEmpty()) { Surface( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt index e414ea26d..d97574a6c 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt @@ -31,8 +31,8 @@ import org.meshtastic.core.resources.position_log import org.meshtastic.core.ui.icon.Delete import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Refresh -import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider import org.meshtastic.core.ui.util.rememberSaveFileLauncher +import org.meshtastic.feature.map.component.NodeTrackMap @Composable fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { @@ -41,9 +41,6 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { val exportPositionLauncher = rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri, positions) } - val trackMap = LocalNodeTrackMapProvider.current - val destNum = state.node?.num ?: 0 - BaseMetricScreen( onNavigateUp = onNavigateUp, telemetryType = null, @@ -66,7 +63,12 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { }, chartPart = { modifier, selectedX, _, onPointSelected -> val selectedTime = selectedX?.toInt() - trackMap(destNum, positions, modifier, selectedTime) { time -> onPointSelected(time.toDouble()) } + NodeTrackMap( + positions = positions, + modifier = modifier, + selectedPositionTime = selectedTime, + onPositionSelected = { time -> onPointSelected(time.toDouble()) }, + ) }, listPart = { modifier, selectedX, lazyListState, onCardClick -> LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) { diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt similarity index 91% rename from feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt rename to feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt index 8a4c0d7d5..e3370a792 100644 --- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -52,8 +52,7 @@ import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Route import org.meshtastic.core.ui.theme.TracerouteColors -import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider -import org.meshtastic.core.ui.util.LocalTracerouteMapProvider +import org.meshtastic.feature.map.component.TracerouteMap import org.meshtastic.proto.Position @Composable @@ -102,7 +101,6 @@ private fun TracerouteMapScaffold( ) { var tracerouteNodesShown by remember { mutableStateOf(0) } var tracerouteNodesTotal by remember { mutableStateOf(0) } - val insets = LocalTracerouteMapOverlayInsetsProvider.current Scaffold( topBar = { MainAppBar( @@ -117,18 +115,18 @@ private fun TracerouteMapScaffold( }, ) { paddingValues -> Box(modifier = modifier.fillMaxSize().padding(paddingValues)) { - LocalTracerouteMapProvider.current( - overlay, - snapshotPositions, - { shown: Int, total: Int -> + TracerouteMap( + tracerouteOverlay = overlay, + tracerouteNodePositions = snapshotPositions, + onMappableCountChanged = { shown: Int, total: Int -> tracerouteNodesShown = shown tracerouteNodesTotal = total }, - Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize(), ) Column( - modifier = Modifier.align(insets.overlayAlignment).padding(insets.overlayPadding), - horizontalAlignment = insets.contentHorizontalAlignment, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { TracerouteNodeCount(shown = tracerouteNodesShown, total = tracerouteNodesTotal) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index facb5a9d7..68e09bec5 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -65,6 +65,7 @@ import org.meshtastic.feature.node.metrics.PositionLogScreen import org.meshtastic.feature.node.metrics.PowerMetricsScreen import org.meshtastic.feature.node.metrics.SignalMetricsScreen import org.meshtastic.feature.node.metrics.TracerouteLogScreen +import org.meshtastic.feature.node.metrics.TracerouteMapScreen import kotlin.reflect.KClass @OptIn(ExperimentalMaterial3AdaptiveApi::class) @@ -142,8 +143,14 @@ fun EntryProviderScope.nodeDetailGraph( } entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> - val tracerouteMapScreen = org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider.current - tracerouteMapScreen(args.destNum, args.requestId, args.logUuid) { backStack.removeLastOrNull() } + val metricsViewModel = koinViewModel { parametersOf(args.destNum) } + metricsViewModel.setNodeId(args.destNum) + TracerouteMapScreen( + metricsViewModel = metricsViewModel, + requestId = args.requestId, + logUuid = args.logUuid, + onNavigateUp = { backStack.removeLastOrNull() }, + ) } NodeDetailScreen.entries.forEach { routeInfo -> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 52d30d1ea..39a45757e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,8 +39,8 @@ compose-multiplatform-material3 = "1.11.0-alpha06" androidx-compose-material = "1.7.8" jetbrains-adaptive = "1.3.0-alpha06" -# Google -maps-compose = "8.3.0" +# MapLibre +maplibre-compose = "0.12.1" # ML Kit mlkit-barcode-scanning = "17.3.0" @@ -64,7 +64,6 @@ firebase-crashlytics-gradle = "3.0.7" google-services-gradle = "4.4.4" markdownRenderer = "0.40.2" okio = "3.17.0" -osmdroid-android = "6.1.20" spotless = "8.4.0" wire = "6.2.0" vico = "3.1.0" @@ -153,11 +152,9 @@ koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", ver koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin" } -maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" } -maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "maps-compose" } -maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" } +maplibre-compose = { module = "org.maplibre.compose:maplibre-compose", version.ref = "maplibre-compose" } +maplibre-compose-material3 = { module = "org.maplibre.compose:maplibre-compose-material3", version.ref = "maplibre-compose" } mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode-scanning" } -play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" } wire-runtime = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" } zxing-core = { module = "com.google.zxing:core", version = "3.5.4" } qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode-kotlin" } @@ -225,9 +222,6 @@ kmqtt-common = { module = "io.github.davidepianca98:kmqtt-common", version.ref = jserialcomm = { module = "com.fazecast:jSerialComm", version.ref = "jserialcomm" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } -osmbonuspack = { module = "com.github.MKergall:osmbonuspack", version = "6.9.0" } -osmdroid-android = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid-android" } -osmdroid-geopackage = { module = "org.osmdroid:osmdroid-geopackage", version.ref = "osmdroid-android" } kermit = { module = "co.touchlab:kermit", version = "2.1.0" } usb-serial-android = { module = "com.github.mik3y:usb-serial-for-android", version = "3.10.0" }