mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-14 02:49:56 -04:00
feat(map): replace Google Maps + OSMDroid with unified MapLibre Compose Multiplatform
Replace the dual flavor-specific map implementations (Google Maps for google, OSMDroid for fdroid) with a single MapLibre Compose Multiplatform implementation in feature:map/commonMain, eliminating ~8,500 lines of duplicated code. Key changes: - Add maplibre-compose v0.12.1 dependency (KMP: Android, Desktop, iOS) - Create unified MapViewModel with camera persistence via MapCameraPrefs - Create MapScreen, MaplibreMapContent, NodeTrackLayers, TracerouteLayers, InlineMap, NodeTrackMap, TracerouteMap, NodeMapScreen in commonMain - Create MapStyle enum with predefined OpenFreeMap tile styles - Create GeoJsonConverters for Node/Waypoint/Position to GeoJSON - Move TracerouteMapScreen from feature:node/androidMain to commonMain - Wire navigation to use direct imports instead of CompositionLocal providers - Delete 61 flavor-specific map files (google + fdroid source sets) - Remove 8 CompositionLocal map providers from core:ui - Remove SharedMapViewModel (replaced by new MapViewModel) - Remove dead google-maps and osmdroid entries from version catalog - Add MapViewModelTest with 10 test cases in commonTest Baseline verified: spotlessCheck, detekt, assembleGoogleDebug, allTests all pass.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import org.meshtastic.core.ui.util.MapViewProvider
|
||||
|
||||
fun getMapViewProvider(): MapViewProvider = FdroidMapViewProvider()
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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()
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<MarkerWithLabel>,
|
||||
waypointMarkers: List<MarkerWithLabel>,
|
||||
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<Waypoint?>(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<Node>): List<MarkerWithLabel> {
|
||||
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<DataPacket>, selectedWaypointId: Int?): List<MarkerWithLabel> {
|
||||
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<Int>() }
|
||||
|
||||
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
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<GeoPoint>, 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<Position>, onClick: (Int) -> Unit): List<Marker> {
|
||||
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
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Int>("waypointId"))
|
||||
val selectedWaypointId: StateFlow<Int?> = _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
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<SourceCount>
|
||||
get() {
|
||||
val db = db
|
||||
val ret: MutableList<SourceCount> = 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<?", arrayOf(nowMillis.toString()))
|
||||
|
||||
class SourceCount {
|
||||
var rowCount: Long = 0
|
||||
var source: String? = null
|
||||
var sizeTotal: Long = 0
|
||||
var sizeMin: Long = 0
|
||||
var sizeMax: Long = 0
|
||||
var sizeAvg: Long = 0
|
||||
}
|
||||
}
|
||||
@@ -1,94 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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 = {})
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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 = {})
|
||||
// }
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<ITileSource, String> =
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Int, Int>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String>,
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String>,
|
||||
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"
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Position>,
|
||||
modifier: Modifier = Modifier,
|
||||
selectedPositionTime: Int? = null,
|
||||
onPositionSelected: ((Int) -> Unit)? = null,
|
||||
) {
|
||||
val vm = koinViewModel<NodeMapViewModel>()
|
||||
vm.setDestNum(destNum)
|
||||
NodeTrackOsmMap(
|
||||
positions = positions,
|
||||
applicationId = vm.applicationId,
|
||||
mapStyleId = vm.mapStyleId,
|
||||
modifier = modifier,
|
||||
selectedPositionTime = selectedPositionTime,
|
||||
onPositionSelected = onPositionSelected,
|
||||
)
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Position>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Int, Position>,
|
||||
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
TracerouteOsmMap(
|
||||
tracerouteOverlay = tracerouteOverlay,
|
||||
tracerouteNodePositions = tracerouteNodePositions,
|
||||
onMappableCountChanged = onMappableCountChanged,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@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<Int, Position>,
|
||||
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<GeoPoint>,
|
||||
returnPoints: List<GeoPoint>,
|
||||
density: androidx.compose.ui.unit.Density,
|
||||
): List<Polyline> {
|
||||
val polylines = mutableListOf<Polyline>()
|
||||
|
||||
fun buildPolyline(points: List<GeoPoint>, 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<GeoPoint>,
|
||||
offsetMeters: Double,
|
||||
headingReferencePoints: List<GeoPoint> = points,
|
||||
sideMultiplier: Double = 1.0,
|
||||
): List<GeoPoint> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import org.meshtastic.core.ui.util.MapViewProvider
|
||||
|
||||
fun getMapViewProvider(): MapViewProvider = GoogleMapViewProvider()
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Int>("waypointId"))
|
||||
val selectedWaypointId: StateFlow<Int?> = _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<Int> = uiPrefs.theme
|
||||
|
||||
private val _errorFlow = MutableSharedFlow<String>()
|
||||
val errorFlow: SharedFlow<String> = _errorFlow.asSharedFlow()
|
||||
|
||||
val customTileProviderConfigs: StateFlow<List<CustomTileProviderConfig>> =
|
||||
customTileProviderRepository.getCustomTileProviders().stateInWhileSubscribed(initialValue = emptyList())
|
||||
|
||||
private val _selectedCustomTileProviderUrl = MutableStateFlow<String?>(null)
|
||||
val selectedCustomTileProviderUrl: StateFlow<String?> = _selectedCustomTileProviderUrl.asStateFlow()
|
||||
|
||||
private val _selectedGoogleMapType = MutableStateFlow(MapType.NORMAL)
|
||||
val selectedGoogleMapType: StateFlow<MapType> = _selectedGoogleMapType.asStateFlow()
|
||||
|
||||
val displayUnits =
|
||||
radioConfigRepository.deviceProfileFlow
|
||||
.mapNotNull { it.config?.display?.units }
|
||||
.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<List<MapLayerItem>>(emptyList())
|
||||
val mapLayers: StateFlow<List<MapLayerItem>> = _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,
|
||||
)
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<NodeClusterItem>,
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<MapLayerItem>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<CustomTileProviderConfig?>(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<String?>(null) }
|
||||
var urlError by remember { mutableStateOf<String?>(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<CustomTileProviderConfig>,
|
||||
currentId: String?,
|
||||
emptyNameError: String,
|
||||
nameExistsError: String,
|
||||
): String? = if (name.isBlank()) {
|
||||
emptyNameError
|
||||
} else if (providers.any { it.name.equals(name, ignoreCase = true) && it.id != currentId }) {
|
||||
nameExistsError
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
private fun validateUrl(url: String, emptyUrlError: String, mustContainPlaceholdersError: String): String? =
|
||||
if (url.isBlank()) {
|
||||
emptyUrlError
|
||||
} else if (
|
||||
!url.contains("{z}", ignoreCase = true) ||
|
||||
!url.contains("{x}", ignoreCase = true) ||
|
||||
!url.contains("{y}", ignoreCase = true)
|
||||
) {
|
||||
mustContainPlaceholdersError
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<NodeClusterItem>,
|
||||
mapFilterState: BaseMapViewModel.MapFilterState,
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
onClusterClick: (Cluster<NodeClusterItem>) -> 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())
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Waypoint>,
|
||||
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
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.model
|
||||
|
||||
class CustomTileSource {
|
||||
|
||||
companion object {
|
||||
fun getTileSource(index: Int) {
|
||||
index
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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]
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Position>,
|
||||
modifier: Modifier = Modifier,
|
||||
selectedPositionTime: Int? = null,
|
||||
onPositionSelected: ((Int) -> Unit)? = null,
|
||||
) {
|
||||
val vm = koinViewModel<NodeMapViewModel>()
|
||||
vm.setDestNum(destNum)
|
||||
val focusedNode by vm.node.collectAsStateWithLifecycle()
|
||||
MapView(
|
||||
modifier = modifier,
|
||||
mode =
|
||||
GoogleMapMode.NodeTrack(
|
||||
focusedNode = focusedNode,
|
||||
positions = positions,
|
||||
selectedPositionTime = selectedPositionTime,
|
||||
onPositionSelected = onPositionSelected,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Preferences> = PreferenceDataStoreFactory.create(
|
||||
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
|
||||
)
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String?>
|
||||
|
||||
fun setSelectedGoogleMapType(value: String?)
|
||||
|
||||
val selectedCustomTileUrl: StateFlow<String?>
|
||||
|
||||
fun setSelectedCustomTileUrl(value: String?)
|
||||
|
||||
val hiddenLayerUrls: StateFlow<Set<String>>
|
||||
|
||||
fun setHiddenLayerUrls(value: Set<String>)
|
||||
|
||||
val cameraTargetLat: StateFlow<Double>
|
||||
|
||||
fun setCameraTargetLat(value: Double)
|
||||
|
||||
val cameraTargetLng: StateFlow<Double>
|
||||
|
||||
fun setCameraTargetLng(value: Double)
|
||||
|
||||
val cameraZoom: StateFlow<Float>
|
||||
|
||||
fun setCameraZoom(value: Float)
|
||||
|
||||
val cameraTilt: StateFlow<Float>
|
||||
|
||||
fun setCameraTilt(value: Float)
|
||||
|
||||
val cameraBearing: StateFlow<Float>
|
||||
|
||||
fun setCameraBearing(value: Float)
|
||||
|
||||
val networkMapLayers: StateFlow<Set<String>>
|
||||
|
||||
fun setNetworkMapLayers(value: Set<String>)
|
||||
}
|
||||
|
||||
@Single
|
||||
class GoogleMapsPrefsImpl(
|
||||
@Named("GoogleMapsDataStore") private val dataStore: DataStore<Preferences>,
|
||||
dispatchers: CoroutineDispatchers,
|
||||
) : GoogleMapsPrefs {
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
|
||||
override val selectedGoogleMapType: StateFlow<String?> =
|
||||
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<String?> =
|
||||
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<Set<String>> =
|
||||
dataStore.data
|
||||
.map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
override fun setHiddenLayerUrls(value: Set<String>) {
|
||||
scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS_PREF] = value } }
|
||||
}
|
||||
|
||||
override val cameraTargetLat: StateFlow<Double> =
|
||||
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<Double> =
|
||||
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<Float> =
|
||||
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<Float> =
|
||||
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<Float> =
|
||||
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<Set<String>> =
|
||||
dataStore.data
|
||||
.map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
override fun setNetworkMapLayers(value: Set<String>) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<List<CustomTileProviderConfig>>
|
||||
|
||||
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<List<CustomTileProviderConfig>>(emptyList())
|
||||
|
||||
init {
|
||||
loadDataFromPrefs()
|
||||
}
|
||||
|
||||
override fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>> =
|
||||
customTileProvidersStateFlow.asStateFlow()
|
||||
|
||||
override suspend fun addCustomTileProvider(config: CustomTileProviderConfig) {
|
||||
val newList = customTileProvidersStateFlow.value + config
|
||||
customTileProvidersStateFlow.value = newList
|
||||
saveDataToPrefs(newList)
|
||||
}
|
||||
|
||||
override suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) {
|
||||
val newList = customTileProvidersStateFlow.value.map { if (it.id == config.id) config else it }
|
||||
customTileProvidersStateFlow.value = newList
|
||||
saveDataToPrefs(newList)
|
||||
}
|
||||
|
||||
override suspend fun deleteCustomTileProvider(configId: String) {
|
||||
val newList = customTileProvidersStateFlow.value.filterNot { it.id == configId }
|
||||
customTileProvidersStateFlow.value = newList
|
||||
saveDataToPrefs(newList)
|
||||
}
|
||||
|
||||
override suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? =
|
||||
customTileProvidersStateFlow.value.find { it.id == configId }
|
||||
|
||||
private fun loadDataFromPrefs() {
|
||||
val jsonString = mapTileProviderPrefs.customTileProviders.value
|
||||
if (jsonString != null) {
|
||||
try {
|
||||
customTileProvidersStateFlow.value = json.decodeFromString<List<CustomTileProviderConfig>>(jsonString)
|
||||
} catch (e: SerializationException) {
|
||||
Logger.e(e) { "Error deserializing tile providers" }
|
||||
customTileProvidersStateFlow.value = emptyList()
|
||||
}
|
||||
} else {
|
||||
customTileProvidersStateFlow.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveDataToPrefs(providers: List<CustomTileProviderConfig>) {
|
||||
withContext(dispatchers.io) {
|
||||
try {
|
||||
val jsonString = json.encodeToString(providers)
|
||||
mapTileProviderPrefs.setCustomTileProviders(jsonString)
|
||||
} catch (e: SerializationException) {
|
||||
Logger.e(e) { "Error serializing tile providers" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Int, Position>,
|
||||
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
MapView(
|
||||
modifier = modifier,
|
||||
mode =
|
||||
GoogleMapMode.Traceroute(
|
||||
overlay = tracerouteOverlay,
|
||||
nodePositions = tracerouteNodePositions,
|
||||
onMappableCountChanged = onMappableCountChanged,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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,
|
||||
)
|
||||
@@ -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<NodeMapViewModel>()
|
||||
vm.setDestNum(destNum)
|
||||
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
|
||||
},
|
||||
LocalTracerouteMapScreenProvider provides
|
||||
{ destNum, requestId, logUuid, onNavigateUp ->
|
||||
val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(destNum) }
|
||||
metricsViewModel.setNodeId(destNum)
|
||||
|
||||
TracerouteMapScreen(
|
||||
metricsViewModel = metricsViewModel,
|
||||
requestId = requestId,
|
||||
logUuid = logUuid,
|
||||
onNavigateUp = onNavigateUp,
|
||||
)
|
||||
},
|
||||
LocalMapMainScreenProvider provides
|
||||
{ onClickNodeChip, navigateToNodeDetails, waypointId ->
|
||||
val viewModel = koinViewModel<SharedMapViewModel>()
|
||||
MapScreen(
|
||||
viewModel = viewModel,
|
||||
onClickNodeChip = onClickNodeChip,
|
||||
navigateToNodeDetails = navigateToNodeDetails,
|
||||
waypointId = waypointId,
|
||||
)
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Preferences>,
|
||||
dispatchers: CoroutineDispatchers,
|
||||
) : MapCameraPrefs {
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
|
||||
override val cameraLat: StateFlow<Double> =
|
||||
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<Double> =
|
||||
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<Float> =
|
||||
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<Float> =
|
||||
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<Float> =
|
||||
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<String> =
|
||||
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<Set<String>> =
|
||||
dataStore.data
|
||||
.map { it[KEY_HIDDEN_LAYER_URLS] ?: emptySet() }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
override fun setHiddenLayerUrls(value: Set<String>) {
|
||||
scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS] = value } }
|
||||
}
|
||||
|
||||
override val networkMapLayers: StateFlow<Set<String>> =
|
||||
dataStore.data
|
||||
.map { it[KEY_NETWORK_MAP_LAYERS] ?: emptySet() }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
override fun setNetworkMapLayers(value: Set<String>) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,41 @@ interface MapPrefs {
|
||||
fun setLastHeardTrackFilter(seconds: Long)
|
||||
}
|
||||
|
||||
/** Reactive interface for map camera position persistence. */
|
||||
interface MapCameraPrefs {
|
||||
val cameraLat: StateFlow<Double>
|
||||
|
||||
fun setCameraLat(value: Double)
|
||||
|
||||
val cameraLng: StateFlow<Double>
|
||||
|
||||
fun setCameraLng(value: Double)
|
||||
|
||||
val cameraZoom: StateFlow<Float>
|
||||
|
||||
fun setCameraZoom(value: Float)
|
||||
|
||||
val cameraTilt: StateFlow<Float>
|
||||
|
||||
fun setCameraTilt(value: Float)
|
||||
|
||||
val cameraBearing: StateFlow<Float>
|
||||
|
||||
fun setCameraBearing(value: Float)
|
||||
|
||||
val selectedStyleUri: StateFlow<String>
|
||||
|
||||
fun setSelectedStyleUri(value: String)
|
||||
|
||||
val hiddenLayerUrls: StateFlow<Set<String>>
|
||||
|
||||
fun setHiddenLayerUrls(value: Set<String>)
|
||||
|
||||
val networkMapLayers: StateFlow<Set<String>>
|
||||
|
||||
fun setNetworkMapLayers(value: Set<String>)
|
||||
}
|
||||
|
||||
/** Reactive interface for map consent. */
|
||||
interface MapConsentPrefs {
|
||||
fun shouldReportLocation(nodeNum: Int?): StateFlow<Boolean>
|
||||
@@ -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
|
||||
|
||||
@@ -1149,6 +1149,11 @@
|
||||
<string name="bluetooth_feature_config">Configuration</string>
|
||||
<string name="bluetooth_feature_config_description">Wirelessly manage your device settings and channels.</string>
|
||||
<string name="map_style_selection">Map style selection</string>
|
||||
<string name="map_style_osm">OpenStreetMap</string>
|
||||
<string name="map_style_satellite">Satellite</string>
|
||||
<string name="map_style_terrain">Terrain</string>
|
||||
<string name="map_style_hybrid">Hybrid</string>
|
||||
<string name="map_style_dark">Dark</string>
|
||||
|
||||
<string name="local_stats_battery">Battery: %1$d%</string>
|
||||
<string name="local_stats_nodes">Nodes: %1$d online / %2$d total</string>
|
||||
|
||||
@@ -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<String>())
|
||||
|
||||
override fun setHiddenLayerUrls(value: Set<String>) {
|
||||
hiddenLayerUrls.value = value
|
||||
}
|
||||
|
||||
override val networkMapLayers = MutableStateFlow(emptySet<String>())
|
||||
|
||||
override fun setNetworkMapLayers(value: Set<String>) {
|
||||
networkMapLayers.value = value
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMapConsentPrefs : MapConsentPrefs {
|
||||
private val consent = mutableMapOf<Int?, MutableStateFlow<Boolean>>()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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> { { _, _ -> } }
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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") }
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)") }
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Position>,
|
||||
modifier: Modifier,
|
||||
selectedPositionTime: Int?,
|
||||
onPositionSelected: ((Int) -> Unit)?,
|
||||
) -> Unit,
|
||||
> {
|
||||
{ _, _, _, _, _ -> PlaceholderScreen("Position Track Map") }
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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() }
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Int, Position>,
|
||||
onMappableCountChanged: (Int, Int) -> Unit,
|
||||
modifier: Modifier,
|
||||
) -> Unit,
|
||||
> {
|
||||
{ _, _, _, _ -> PlaceholderScreen("Traceroute Map") }
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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") }
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<MapViewProvider?> { null }
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Application>(MockMode.autofill)
|
||||
private val mapPrefs = mock<MapPrefs>(MockMode.autofill)
|
||||
private val googleMapsPrefs = mock<GoogleMapsPrefs>(MockMode.autofill)
|
||||
private val nodeRepository = FakeNodeRepository()
|
||||
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
|
||||
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
|
||||
private val radioController = FakeRadioController()
|
||||
private val customTileProviderRepository = mock<CustomTileProviderRepository>(MockMode.autofill)
|
||||
private val uiPrefs = mock<UiPrefs>(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)
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Int?>(savedStateHandle.get<Int?>("waypointId"))
|
||||
val selectedWaypointId: StateFlow<Int?> = 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<BaseStyle> =
|
||||
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<MapStyle> =
|
||||
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
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Node>,
|
||||
waypoints: Map<Int, DataPacket>,
|
||||
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<Node>,
|
||||
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<Int, DataPacket>) {
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<org.meshtastic.proto.Position>,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Position>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Int, org.meshtastic.proto.Position>,
|
||||
nodes: Map<Int, Node>,
|
||||
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<LineString, JsonObject>,
|
||||
val returnLine: FeatureCollection<LineString, JsonObject>,
|
||||
val hopFeatures: FeatureCollection<Point, JsonObject>,
|
||||
)
|
||||
|
||||
private fun buildTracerouteGeoJson(
|
||||
overlay: TracerouteOverlay,
|
||||
nodePositions: Map<Int, org.meshtastic.proto.Position>,
|
||||
nodes: Map<Int, Node>,
|
||||
): 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<LineString, JsonObject>
|
||||
} else {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
FeatureCollection(emptyList<Feature<LineString, JsonObject>>()) as FeatureCollection<LineString, JsonObject>
|
||||
}
|
||||
|
||||
// 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<LineString, JsonObject>
|
||||
} else {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
FeatureCollection(emptyList<Feature<LineString, JsonObject>>()) as FeatureCollection<LineString, JsonObject>
|
||||
}
|
||||
|
||||
// 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<Point, JsonObject>,
|
||||
)
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Int, Position>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
@@ -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<NavKey>.mapGraph(backStack: NavBackStack<NavKey>) {
|
||||
entry<MapRoute.Map> { 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<MapViewModel>()
|
||||
MapScreen(
|
||||
viewModel = viewModel,
|
||||
onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) },
|
||||
navigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) },
|
||||
waypointId = args.waypointId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Node>, myNodeNum: Int? = null): FeatureCollection<Point, JsonObject> {
|
||||
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<Point, JsonObject>
|
||||
}
|
||||
|
||||
/** Convert waypoints to a GeoJSON [FeatureCollection]. */
|
||||
fun waypointsToFeatureCollection(waypoints: Map<Int, DataPacket>): FeatureCollection<Point, JsonObject> {
|
||||
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<Point, JsonObject>
|
||||
}
|
||||
|
||||
/** Convert position history to a GeoJSON [LineString] for track rendering. */
|
||||
fun positionsToLineString(positions: List<org.meshtastic.proto.Position>): FeatureCollection<LineString, JsonObject> {
|
||||
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<LineString, JsonObject>
|
||||
}
|
||||
|
||||
/** Convert position history to individual point features with time metadata. */
|
||||
fun positionsToPointFeatures(positions: List<org.meshtastic.proto.Position>): FeatureCollection<Point, JsonObject> {
|
||||
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<Point, JsonObject>
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
@@ -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<NavKey>.nodeDetailGraph(
|
||||
}
|
||||
|
||||
entry<NodeDetailRoute.TracerouteMap>(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<MetricsViewModel> { parametersOf(args.destNum) }
|
||||
metricsViewModel.setNodeId(args.destNum)
|
||||
TracerouteMapScreen(
|
||||
metricsViewModel = metricsViewModel,
|
||||
requestId = args.requestId,
|
||||
logUuid = args.logUuid,
|
||||
onNavigateUp = { backStack.removeLastOrNull() },
|
||||
)
|
||||
}
|
||||
|
||||
NodeDetailScreen.entries.forEach { routeInfo ->
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user