mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
More map modularization (#3319)
This commit is contained in:
parent
bc114c618a
commit
51fa634e11
28 changed files with 145 additions and 146 deletions
|
|
@ -1,751 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:Suppress("MagicNumber")
|
||||
|
||||
package com.geeksville.mesh.ui.map
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.net.Uri
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.filled.TripOrigin
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
|
||||
import com.geeksville.mesh.MeshProtos.Position
|
||||
import com.geeksville.mesh.MeshProtos.Waypoint
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.ui.map.components.ClusterItemsListDialog
|
||||
import com.geeksville.mesh.ui.map.components.EditWaypointDialog
|
||||
import com.geeksville.mesh.ui.map.components.NodeClusterMarkers
|
||||
import com.geeksville.mesh.ui.map.components.WaypointMarkers
|
||||
import com.geeksville.mesh.ui.metrics.HEADING_DEG
|
||||
import com.geeksville.mesh.ui.metrics.formatPositionTime
|
||||
import com.geeksville.mesh.ui.node.DEG_D
|
||||
import com.geeksville.mesh.waypoint
|
||||
import com.google.android.gms.location.LocationCallback
|
||||
import com.google.android.gms.location.LocationRequest
|
||||
import com.google.android.gms.location.LocationResult
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import com.google.android.gms.maps.CameraUpdateFactory
|
||||
import com.google.android.gms.maps.model.BitmapDescriptor
|
||||
import com.google.android.gms.maps.model.BitmapDescriptorFactory
|
||||
import com.google.android.gms.maps.model.CameraPosition
|
||||
import com.google.android.gms.maps.model.JointType
|
||||
import com.google.android.gms.maps.model.LatLng
|
||||
import com.google.android.gms.maps.model.LatLngBounds
|
||||
import com.google.maps.android.clustering.ClusterItem
|
||||
import com.google.maps.android.compose.ComposeMapColorScheme
|
||||
import com.google.maps.android.compose.GoogleMap
|
||||
import com.google.maps.android.compose.MapEffect
|
||||
import com.google.maps.android.compose.MapProperties
|
||||
import com.google.maps.android.compose.MapType
|
||||
import com.google.maps.android.compose.MapUiSettings
|
||||
import com.google.maps.android.compose.MapsComposeExperimentalApi
|
||||
import com.google.maps.android.compose.MarkerComposable
|
||||
import com.google.maps.android.compose.MarkerInfoWindowComposable
|
||||
import com.google.maps.android.compose.Polyline
|
||||
import com.google.maps.android.compose.TileOverlay
|
||||
import com.google.maps.android.compose.rememberCameraPositionState
|
||||
import com.google.maps.android.compose.rememberUpdatedMarkerState
|
||||
import com.google.maps.android.compose.widgets.ScaleBar
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.util.formatAgo
|
||||
import org.meshtastic.core.model.util.metersIn
|
||||
import org.meshtastic.core.model.util.mpsToKmph
|
||||
import org.meshtastic.core.model.util.mpsToMph
|
||||
import org.meshtastic.core.model.util.toString
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.NodeChip
|
||||
import org.meshtastic.feature.map.LastHeardFilter
|
||||
import org.meshtastic.feature.map.LayerType
|
||||
import org.meshtastic.feature.map.LocationPermissionsHandler
|
||||
import org.meshtastic.feature.map.MapViewModel
|
||||
import org.meshtastic.feature.map.component.CustomMapLayersSheet
|
||||
import org.meshtastic.feature.map.component.CustomTileProviderManagerSheet
|
||||
import org.meshtastic.feature.map.component.MapControlsOverlay
|
||||
import timber.log.Timber
|
||||
import java.text.DateFormat
|
||||
|
||||
private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun MapView(
|
||||
mapViewModel: MapViewModel = hiltViewModel(),
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
focusedNodeNum: Int? = null,
|
||||
nodeTracks: List<Position>? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
|
||||
var hasLocationPermission by remember { mutableStateOf(false) }
|
||||
val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
|
||||
|
||||
// Location tracking state
|
||||
var isLocationTrackingEnabled by remember { mutableStateOf(false) }
|
||||
var followPhoneBearing by remember { mutableStateOf(false) }
|
||||
|
||||
LocationPermissionsHandler { isGranted -> hasLocationPermission = isGranted }
|
||||
|
||||
val filePickerLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
val fileName = uri.getFileName(context)
|
||||
mapViewModel.addMapLayer(uri, fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var mapFilterMenuExpanded by remember { mutableStateOf(false) }
|
||||
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
|
||||
val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
var editingWaypoint by remember { mutableStateOf<Waypoint?>(null) }
|
||||
|
||||
val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
|
||||
val currentCustomTileProviderUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
|
||||
|
||||
var mapTypeMenuExpanded by remember { mutableStateOf(false) }
|
||||
var showCustomTileManagerSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val cameraPositionState = rememberCameraPositionState {
|
||||
position =
|
||||
CameraPosition.fromLatLngZoom(
|
||||
LatLng(
|
||||
ourNodeInfo?.position?.latitudeI?.times(DEG_D) ?: 0.0,
|
||||
ourNodeInfo?.position?.longitudeI?.times(DEG_D) ?: 0.0,
|
||||
),
|
||||
7f,
|
||||
)
|
||||
}
|
||||
|
||||
// Location tracking functionality
|
||||
val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
|
||||
val locationCallback = remember {
|
||||
object : LocationCallback() {
|
||||
override fun onLocationResult(locationResult: LocationResult) {
|
||||
if (isLocationTrackingEnabled) {
|
||||
locationResult.lastLocation?.let { location ->
|
||||
val latLng = LatLng(location.latitude, location.longitude)
|
||||
val cameraUpdate =
|
||||
if (followPhoneBearing) {
|
||||
val bearing =
|
||||
if (location.hasBearing()) {
|
||||
location.bearing
|
||||
} else {
|
||||
cameraPositionState.position.bearing
|
||||
}
|
||||
CameraUpdateFactory.newCameraPosition(
|
||||
CameraPosition.Builder()
|
||||
.target(latLng)
|
||||
.zoom(cameraPositionState.position.zoom)
|
||||
.bearing(bearing)
|
||||
.build(),
|
||||
)
|
||||
} else {
|
||||
CameraUpdateFactory.newLatLngZoom(latLng, cameraPositionState.position.zoom)
|
||||
}
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
cameraPositionState.animate(cameraUpdate)
|
||||
} catch (e: IllegalStateException) {
|
||||
Timber.d("Error animating camera to location: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start/stop location tracking based on state
|
||||
LaunchedEffect(isLocationTrackingEnabled, hasLocationPermission) {
|
||||
if (isLocationTrackingEnabled && hasLocationPermission) {
|
||||
val locationRequest =
|
||||
LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L)
|
||||
.setMinUpdateIntervalMillis(2000L)
|
||||
.build()
|
||||
|
||||
try {
|
||||
fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null)
|
||||
Timber.d("Started location tracking")
|
||||
} catch (e: SecurityException) {
|
||||
Timber.d("Location permission not available: ${e.message}")
|
||||
isLocationTrackingEnabled = false
|
||||
}
|
||||
} else {
|
||||
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||
Timber.d("Stopped location tracking")
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||
mapViewModel.clearLoadedLayerData()
|
||||
}
|
||||
}
|
||||
|
||||
val allNodes by
|
||||
mapViewModel.nodes
|
||||
.map { nodes -> nodes.filter { node -> node.validPosition != null } }
|
||||
.collectAsStateWithLifecycle(listOf())
|
||||
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
|
||||
val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint }
|
||||
|
||||
val filteredNodes =
|
||||
allNodes
|
||||
.filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num }
|
||||
.filter { node ->
|
||||
mapFilterState.lastHeardFilter.seconds == 0L ||
|
||||
(System.currentTimeMillis() / 1000 - node.lastHeard) <= mapFilterState.lastHeardFilter.seconds ||
|
||||
node.num == ourNodeInfo?.num
|
||||
}
|
||||
|
||||
val nodeClusterItems =
|
||||
filteredNodes.map { node ->
|
||||
val latLng = LatLng(node.position.latitudeI * DEG_D, node.position.longitudeI * DEG_D)
|
||||
NodeClusterItem(
|
||||
node = node,
|
||||
nodePosition = latLng,
|
||||
nodeTitle = "${node.user.shortName} ${formatAgo(node.position.time)}",
|
||||
nodeSnippet = "${node.user.longName}",
|
||||
)
|
||||
}
|
||||
val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle()
|
||||
val theme by mapViewModel.theme.collectAsStateWithLifecycle()
|
||||
val dark =
|
||||
when (theme) {
|
||||
AppCompatDelegate.MODE_NIGHT_YES -> true
|
||||
AppCompatDelegate.MODE_NIGHT_NO -> false
|
||||
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme()
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
val mapColorScheme =
|
||||
when (dark) {
|
||||
true -> ComposeMapColorScheme.DARK
|
||||
else -> ComposeMapColorScheme.LIGHT
|
||||
}
|
||||
|
||||
var showLayersBottomSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val onAddLayerClicked = {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "*/*"
|
||||
val mimeTypes =
|
||||
arrayOf(
|
||||
"application/vnd.google-earth.kml+xml",
|
||||
"application/vnd.google-earth.kmz",
|
||||
"application/vnd.geo+json",
|
||||
"application/geo+json",
|
||||
"application/json",
|
||||
)
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
|
||||
}
|
||||
filePickerLauncher.launch(intent)
|
||||
}
|
||||
val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
|
||||
val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
|
||||
|
||||
val effectiveGoogleMapType =
|
||||
if (currentCustomTileProviderUrl != null) {
|
||||
MapType.NONE
|
||||
} else {
|
||||
selectedGoogleMapType
|
||||
}
|
||||
|
||||
var showClusterItemsDialog by remember { mutableStateOf<List<NodeClusterItem>?>(null) }
|
||||
|
||||
LaunchedEffect(isLocationTrackingEnabled) {
|
||||
val activity = context as? Activity ?: return@LaunchedEffect
|
||||
val window = activity.window
|
||||
|
||||
if (isLocationTrackingEnabled) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold { paddingValues ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
|
||||
GoogleMap(
|
||||
mapColorScheme = mapColorScheme,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
cameraPositionState = cameraPositionState,
|
||||
uiSettings =
|
||||
MapUiSettings(
|
||||
zoomControlsEnabled = true,
|
||||
mapToolbarEnabled = true,
|
||||
compassEnabled = false,
|
||||
myLocationButtonEnabled = false,
|
||||
rotationGesturesEnabled = true,
|
||||
scrollGesturesEnabled = true,
|
||||
tiltGesturesEnabled = true,
|
||||
zoomGesturesEnabled = true,
|
||||
),
|
||||
properties =
|
||||
MapProperties(mapType = effectiveGoogleMapType, isMyLocationEnabled = hasLocationPermission),
|
||||
onMapLongClick = { latLng ->
|
||||
if (isConnected) {
|
||||
val newWaypoint = waypoint {
|
||||
latitudeI = (latLng.latitude / DEG_D).toInt()
|
||||
longitudeI = (latLng.longitude / DEG_D).toInt()
|
||||
}
|
||||
editingWaypoint = newWaypoint
|
||||
}
|
||||
},
|
||||
onMapLoaded = {
|
||||
val pointsToBound: List<LatLng> =
|
||||
when {
|
||||
!nodeTracks.isNullOrEmpty() -> nodeTracks.map { it.toLatLng() }
|
||||
|
||||
allNodes.isNotEmpty() || displayableWaypoints.isNotEmpty() ->
|
||||
allNodes.mapNotNull { it.toLatLng() } + displayableWaypoints.map { it.toLatLng() }
|
||||
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
if (pointsToBound.isNotEmpty()) {
|
||||
val bounds = LatLngBounds.builder().apply { pointsToBound.forEach(::include) }.build()
|
||||
|
||||
val padding = if (!pointsToBound.isEmpty()) 100 else 48
|
||||
|
||||
try {
|
||||
coroutineScope.launch {
|
||||
cameraPositionState.animate(CameraUpdateFactory.newLatLngBounds(bounds, padding))
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
Timber.w("MapView Could not animate to bounds: ${e.message}")
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
key(currentCustomTileProviderUrl) {
|
||||
currentCustomTileProviderUrl?.let { url ->
|
||||
mapViewModel.createUrlTileProvider(url)?.let { tileProvider ->
|
||||
TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeTracks != null && focusedNodeNum != null) {
|
||||
val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
|
||||
val timeFilteredPositions =
|
||||
nodeTracks.filter {
|
||||
lastHeardTrackFilter == LastHeardFilter.Any ||
|
||||
it.time > System.currentTimeMillis() / 1000 - lastHeardTrackFilter.seconds
|
||||
}
|
||||
val sortedPositions = timeFilteredPositions.sortedBy { it.time }
|
||||
allNodes
|
||||
.find { it.num == focusedNodeNum }
|
||||
?.let { focusedNode ->
|
||||
sortedPositions.forEachIndexed { index, position ->
|
||||
val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
|
||||
val dateFormat = remember {
|
||||
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
|
||||
}
|
||||
val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1))
|
||||
val color = Color(focusedNode!!.colors.second).copy(alpha = alpha)
|
||||
if (index == sortedPositions.lastIndex) {
|
||||
MarkerComposable(state = markerState, zIndex = 1f) { NodeChip(node = focusedNode) }
|
||||
} else {
|
||||
MarkerInfoWindowComposable(
|
||||
state = markerState,
|
||||
title = stringResource(R.string.position),
|
||||
snippet = formatAgo(position.time),
|
||||
zIndex = alpha,
|
||||
infoContent = {
|
||||
PositionInfoWindowContent(
|
||||
position = position,
|
||||
dateFormat = dateFormat,
|
||||
displayUnits = displayUnits,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.TripOrigin,
|
||||
contentDescription = stringResource(R.string.track_point),
|
||||
tint = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sortedPositions.size > 1 && focusedNode != null) {
|
||||
val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false)
|
||||
segments.forEachIndexed { index, segmentPoints ->
|
||||
val alpha = (index.toFloat() / (segments.size.toFloat() - 1))
|
||||
Polyline(
|
||||
points = segmentPoints.map { it.toLatLng() },
|
||||
jointType = JointType.ROUND,
|
||||
color = Color(focusedNode.colors.second).copy(alpha = alpha),
|
||||
width = 8f,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
NodeClusterMarkers(
|
||||
nodeClusterItems = nodeClusterItems,
|
||||
mapFilterState = mapFilterState,
|
||||
navigateToNodeDetails = navigateToNodeDetails,
|
||||
onClusterClick = { cluster ->
|
||||
val items = cluster.items.toList()
|
||||
val allSameLocation = items.size > 1 && items.all { it.position == items.first().position }
|
||||
|
||||
if (allSameLocation) {
|
||||
showClusterItemsDialog = items
|
||||
} else {
|
||||
val bounds = LatLngBounds.builder()
|
||||
cluster.items.forEach { bounds.include(it.position) }
|
||||
coroutineScope.launch {
|
||||
cameraPositionState.animate(
|
||||
CameraUpdateFactory.newLatLngBounds(bounds.build(), 100),
|
||||
)
|
||||
}
|
||||
Timber.d("Cluster clicked! $cluster")
|
||||
}
|
||||
true
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
WaypointMarkers(
|
||||
displayableWaypoints = displayableWaypoints,
|
||||
mapFilterState = mapFilterState,
|
||||
myNodeNum = mapViewModel.myNodeNum ?: 0,
|
||||
isConnected = isConnected,
|
||||
unicodeEmojiToBitmapProvider = ::unicodeEmojiToBitmap,
|
||||
onEditWaypointRequest = { waypointToEdit -> editingWaypoint = waypointToEdit },
|
||||
)
|
||||
|
||||
MapEffect(mapLayers) { map ->
|
||||
mapLayers.forEach { layerItem ->
|
||||
coroutineScope.launch {
|
||||
mapViewModel.loadMapLayerIfNeeded(map, layerItem)
|
||||
when (layerItem.layerType) {
|
||||
LayerType.KML -> {
|
||||
layerItem.kmlLayerData?.let { kmlLayer ->
|
||||
if (layerItem.isVisible && !kmlLayer.isLayerOnMap) {
|
||||
kmlLayer.addLayerToMap()
|
||||
} else if (!layerItem.isVisible && kmlLayer.isLayerOnMap) {
|
||||
kmlLayer.removeLayerFromMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LayerType.GEOJSON -> {
|
||||
layerItem.geoJsonLayerData?.let { geoJsonLayer ->
|
||||
if (layerItem.isVisible && !geoJsonLayer.isLayerOnMap) {
|
||||
geoJsonLayer.addLayerToMap()
|
||||
} else if (!layerItem.isVisible && geoJsonLayer.isLayerOnMap) {
|
||||
geoJsonLayer.removeLayerFromMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScaleBar(
|
||||
cameraPositionState = cameraPositionState,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 48.dp),
|
||||
)
|
||||
editingWaypoint?.let { waypointToEdit ->
|
||||
EditWaypointDialog(
|
||||
waypoint = waypointToEdit,
|
||||
onSendClicked = { updatedWp ->
|
||||
var finalWp = updatedWp
|
||||
if (updatedWp.id == 0) {
|
||||
finalWp = finalWp.copy { id = mapViewModel.generatePacketId() ?: 0 }
|
||||
}
|
||||
if (updatedWp.icon == 0) {
|
||||
finalWp = finalWp.copy { icon = 0x1F4CD }
|
||||
}
|
||||
|
||||
mapViewModel.sendWaypoint(finalWp)
|
||||
editingWaypoint = null
|
||||
},
|
||||
onDeleteClicked = { wpToDelete ->
|
||||
if (wpToDelete.lockedTo == 0 && isConnected && wpToDelete.id != 0) {
|
||||
val deleteMarkerWp = wpToDelete.copy { expire = 1 }
|
||||
mapViewModel.sendWaypoint(deleteMarkerWp)
|
||||
}
|
||||
mapViewModel.deleteWaypoint(wpToDelete.id)
|
||||
editingWaypoint = null
|
||||
},
|
||||
onDismissRequest = { editingWaypoint = null },
|
||||
)
|
||||
}
|
||||
|
||||
MapControlsOverlay(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
|
||||
mapFilterMenuExpanded = mapFilterMenuExpanded,
|
||||
onMapFilterMenuDismissRequest = { mapFilterMenuExpanded = false },
|
||||
onToggleMapFilterMenu = { mapFilterMenuExpanded = true },
|
||||
mapViewModel = mapViewModel,
|
||||
mapTypeMenuExpanded = mapTypeMenuExpanded,
|
||||
onMapTypeMenuDismissRequest = { mapTypeMenuExpanded = false },
|
||||
onToggleMapTypeMenu = { mapTypeMenuExpanded = true },
|
||||
onManageLayersClicked = { showLayersBottomSheet = true },
|
||||
onManageCustomTileProvidersClicked = {
|
||||
mapTypeMenuExpanded = false
|
||||
showCustomTileManagerSheet = true
|
||||
},
|
||||
isNodeMap = focusedNodeNum != null,
|
||||
hasLocationPermission = hasLocationPermission,
|
||||
isLocationTrackingEnabled = isLocationTrackingEnabled,
|
||||
onToggleLocationTracking = {
|
||||
if (hasLocationPermission) {
|
||||
isLocationTrackingEnabled = !isLocationTrackingEnabled
|
||||
if (!isLocationTrackingEnabled) {
|
||||
followPhoneBearing = false
|
||||
}
|
||||
}
|
||||
},
|
||||
bearing = cameraPositionState.position.bearing,
|
||||
onCompassClick = {
|
||||
if (isLocationTrackingEnabled) {
|
||||
followPhoneBearing = !followPhoneBearing
|
||||
} else {
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
val currentPosition = cameraPositionState.position
|
||||
val newCameraPosition = CameraPosition.Builder(currentPosition).bearing(0f).build()
|
||||
cameraPositionState.animate(CameraUpdateFactory.newCameraPosition(newCameraPosition))
|
||||
Timber.d("Oriented map to north")
|
||||
} catch (e: IllegalStateException) {
|
||||
Timber.d("Error orienting map to north: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
followPhoneBearing = followPhoneBearing,
|
||||
)
|
||||
}
|
||||
if (showLayersBottomSheet) {
|
||||
ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) {
|
||||
CustomMapLayersSheet(mapLayers, onToggleVisibility, onRemoveLayer, onAddLayerClicked)
|
||||
}
|
||||
}
|
||||
showClusterItemsDialog?.let {
|
||||
ClusterItemsListDialog(
|
||||
items = it,
|
||||
onDismiss = { showClusterItemsDialog = null },
|
||||
onItemClick = { item ->
|
||||
navigateToNodeDetails(item.node.num)
|
||||
showClusterItemsDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showCustomTileManagerSheet) {
|
||||
ModalBottomSheet(onDismissRequest = { showCustomTileManagerSheet = false }) {
|
||||
CustomTileProviderManagerSheet(mapViewModel = mapViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
|
||||
String(Character.toChars(unicodeCodePoint))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.w(e, "Invalid unicode code point: $unicodeCodePoint")
|
||||
"\uD83D\uDCCD"
|
||||
}
|
||||
|
||||
internal fun unicodeEmojiToBitmap(icon: Int): BitmapDescriptor {
|
||||
val unicodeEmoji = convertIntToEmoji(icon)
|
||||
val paint =
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textSize = 64f
|
||||
color = android.graphics.Color.BLACK
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
val baseline = -paint.ascent()
|
||||
val width = (paint.measureText(unicodeEmoji) + 0.5f).toInt()
|
||||
val height = (baseline + paint.descent() + 0.5f).toInt()
|
||||
val image = createBitmap(width, height, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(image)
|
||||
canvas.drawText(unicodeEmoji, width / 2f, baseline, paint)
|
||||
|
||||
return BitmapDescriptorFactory.fromBitmap(image)
|
||||
}
|
||||
|
||||
@Suppress("NestedBlockDepth")
|
||||
fun Uri.getFileName(context: android.content.Context): String {
|
||||
var name = this.lastPathSegment ?: "layer_${System.currentTimeMillis()}"
|
||||
if (this.scheme == "content") {
|
||||
context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (displayNameIndex != -1) {
|
||||
name = cursor.getString(displayNameIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
data class NodeClusterItem(val node: Node, val nodePosition: LatLng, val nodeTitle: String, val nodeSnippet: String) :
|
||||
ClusterItem {
|
||||
override fun getPosition(): LatLng = nodePosition
|
||||
|
||||
override fun getTitle(): String = nodeTitle
|
||||
|
||||
override fun getSnippet(): String = nodeSnippet
|
||||
|
||||
override fun getZIndex(): Float? = null
|
||||
|
||||
fun getPrecisionMeters(): Double? {
|
||||
val precisionMap =
|
||||
mapOf(
|
||||
10 to 23345.484932,
|
||||
11 to 11672.7369,
|
||||
12 to 5836.36288,
|
||||
13 to 2918.175876,
|
||||
14 to 1459.0823719999053,
|
||||
15 to 729.53562,
|
||||
16 to 364.7622,
|
||||
17 to 182.375556,
|
||||
18 to 91.182212,
|
||||
19 to 45.58554,
|
||||
)
|
||||
return precisionMap[this.node.position.precisionBits]
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
private fun PositionInfoWindowContent(
|
||||
position: Position,
|
||||
dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM),
|
||||
displayUnits: DisplayUnits = DisplayUnits.METRIC,
|
||||
) {
|
||||
@Composable
|
||||
fun PositionRow(label: String, value: String) {
|
||||
Row(modifier = Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(value, style = MaterialTheme.typography.labelMediumEmphasized)
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
PositionRow(
|
||||
label = stringResource(R.string.latitude),
|
||||
value = "%.5f".format(position.latitudeI * com.geeksville.mesh.ui.metrics.DEG_D),
|
||||
)
|
||||
|
||||
PositionRow(
|
||||
label = stringResource(R.string.longitude),
|
||||
value = "%.5f".format(position.longitudeI * com.geeksville.mesh.ui.metrics.DEG_D),
|
||||
)
|
||||
|
||||
PositionRow(label = stringResource(R.string.sats), value = position.satsInView.toString())
|
||||
|
||||
PositionRow(
|
||||
label = stringResource(R.string.alt),
|
||||
value = position.altitude.metersIn(displayUnits).toString(displayUnits),
|
||||
)
|
||||
|
||||
PositionRow(label = stringResource(R.string.speed), value = speedFromPosition(position, displayUnits))
|
||||
|
||||
PositionRow(
|
||||
label = stringResource(R.string.heading),
|
||||
value = "%.0f°".format(position.groundTrack * HEADING_DEG),
|
||||
)
|
||||
|
||||
PositionRow(label = stringResource(R.string.timestamp), value = formatPositionTime(position, dateFormat))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String {
|
||||
val speedInMps = position.groundSpeed
|
||||
val mpsText = "%d m/s".format(speedInMps)
|
||||
val speedText =
|
||||
if (speedInMps > 10) {
|
||||
when (displayUnits) {
|
||||
DisplayUnits.METRIC -> "%.1f Km/h".format(position.groundSpeed.mpsToKmph())
|
||||
DisplayUnits.IMPERIAL -> "%.1f mph".format(position.groundSpeed.mpsToMph())
|
||||
else -> mpsText // Fallback or handle UNRECOGNIZED
|
||||
}
|
||||
} else {
|
||||
mpsText
|
||||
}
|
||||
return speedText
|
||||
}
|
||||
|
||||
private fun Position.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D)
|
||||
|
||||
private fun Node.toLatLng(): LatLng? = this.position.toLatLng()
|
||||
|
||||
private fun Waypoint.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D)
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.map.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.ui.map.NodeClusterItem
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.NodeChip
|
||||
|
||||
@Composable
|
||||
fun ClusterItemsListDialog(
|
||||
items: List<NodeClusterItem>,
|
||||
onDismiss: () -> Unit,
|
||||
onItemClick: (NodeClusterItem) -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = stringResource(R.string.nodes_at_this_location)) },
|
||||
text = {
|
||||
// Use a LazyColumn for potentially long lists of items
|
||||
LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||
items(items, key = { it.node.num }) { item ->
|
||||
ClusterDialogListItem(item = item, onClick = { onItemClick(item) })
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.okay)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClusterDialogListItem(item: NodeClusterItem, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
ListItem(
|
||||
leadingContent = { NodeChip(node = item.node) },
|
||||
headlineContent = { Text(item.nodeTitle) },
|
||||
supportingContent = {
|
||||
if (item.nodeSnippet.isNotBlank()) {
|
||||
Text(item.nodeSnippet)
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp), // Add some padding around list items
|
||||
)
|
||||
}
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.map.components
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.TimePickerDialog
|
||||
import android.widget.DatePicker
|
||||
import android.widget.TimePicker
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.geeksville.mesh.MeshProtos.Waypoint
|
||||
import com.geeksville.mesh.copy
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
|
||||
@Composable
|
||||
fun EditWaypointDialog(
|
||||
waypoint: Waypoint,
|
||||
onSendClicked: (Waypoint) -> Unit,
|
||||
onDeleteClicked: (Waypoint) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var waypointInput by remember { mutableStateOf(waypoint) }
|
||||
val title = if (waypoint.id == 0) R.string.waypoint_new else R.string.waypoint_edit
|
||||
val defaultEmoji = 0x1F4CD // 📍 Round Pushpin
|
||||
val currentEmojiCodepoint = if (waypointInput.icon == 0) defaultEmoji else waypointInput.icon
|
||||
var showEmojiPickerView by remember { mutableStateOf(false) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val calendar = remember { Calendar.getInstance() }
|
||||
|
||||
// Initialize date and time states from waypointInput.expire
|
||||
var selectedDateString by remember { mutableStateOf("") }
|
||||
var selectedTimeString by remember { mutableStateOf("") }
|
||||
var isExpiryEnabled by remember {
|
||||
mutableStateOf(waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE)
|
||||
}
|
||||
|
||||
val locale = Locale.getDefault()
|
||||
val dateFormat = remember {
|
||||
if (locale.country.equals("US", ignoreCase = true)) {
|
||||
SimpleDateFormat("MM/dd/yyyy", locale)
|
||||
} else {
|
||||
SimpleDateFormat("dd/MM/yyyy", locale)
|
||||
}
|
||||
}
|
||||
val timeFormat = remember {
|
||||
val is24Hour = android.text.format.DateFormat.is24HourFormat(context)
|
||||
if (is24Hour) {
|
||||
SimpleDateFormat("HH:mm", locale)
|
||||
} else {
|
||||
SimpleDateFormat("hh:mm a", locale)
|
||||
}
|
||||
}
|
||||
dateFormat.timeZone = TimeZone.getDefault()
|
||||
timeFormat.timeZone = TimeZone.getDefault()
|
||||
|
||||
LaunchedEffect(waypointInput.expire, isExpiryEnabled) {
|
||||
if (isExpiryEnabled) {
|
||||
if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
|
||||
calendar.timeInMillis = waypointInput.expire * 1000L
|
||||
selectedDateString = dateFormat.format(calendar.time)
|
||||
selectedTimeString = timeFormat.format(calendar.time)
|
||||
} else { // If enabled but not set, default to 8 hours from now
|
||||
calendar.timeInMillis = System.currentTimeMillis()
|
||||
calendar.add(Calendar.HOUR_OF_DAY, 8)
|
||||
waypointInput = waypointInput.copy { expire = (calendar.timeInMillis / 1000).toInt() }
|
||||
}
|
||||
} else {
|
||||
selectedDateString = ""
|
||||
selectedTimeString = ""
|
||||
}
|
||||
}
|
||||
|
||||
if (!showEmojiPickerView) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(title),
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = waypointInput.name,
|
||||
onValueChange = { waypointInput = waypointInput.copy { name = it.take(29) } },
|
||||
label = { Text(stringResource(R.string.name)) },
|
||||
singleLine = true,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showEmojiPickerView = true }) {
|
||||
Text(
|
||||
text = String(Character.toChars(currentEmojiCodepoint)),
|
||||
modifier =
|
||||
Modifier.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape)
|
||||
.padding(6.dp),
|
||||
fontSize = 20.sp,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
OutlinedTextField(
|
||||
value = waypointInput.description,
|
||||
onValueChange = { waypointInput = waypointInput.copy { description = it.take(99) } },
|
||||
label = { Text(stringResource(R.string.description)) },
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { /* Handle next/done focus */ }),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
maxLines = 3,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = stringResource(R.string.locked),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.locked))
|
||||
}
|
||||
Switch(
|
||||
checked = waypointInput.lockedTo != 0,
|
||||
onCheckedChange = { waypointInput = waypointInput.copy { lockedTo = if (it) 1 else 0 } },
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
imageVector = Icons.Default.CalendarMonth,
|
||||
contentDescription = stringResource(R.string.expires),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.expires))
|
||||
}
|
||||
Switch(
|
||||
checked = isExpiryEnabled,
|
||||
onCheckedChange = { checked ->
|
||||
isExpiryEnabled = checked
|
||||
if (checked) {
|
||||
// Default to 8 hours from now if not already set
|
||||
if (waypointInput.expire == 0 || waypointInput.expire == Int.MAX_VALUE) {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.timeInMillis = System.currentTimeMillis()
|
||||
cal.add(Calendar.HOUR_OF_DAY, 8)
|
||||
waypointInput =
|
||||
waypointInput.copy { expire = (cal.timeInMillis / 1000).toInt() }
|
||||
}
|
||||
// LaunchedEffect will update date/time strings
|
||||
} else {
|
||||
waypointInput = waypointInput.copy { expire = Int.MAX_VALUE }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isExpiryEnabled) {
|
||||
val currentCalendar =
|
||||
Calendar.getInstance().apply {
|
||||
if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
|
||||
timeInMillis = waypointInput.expire * 1000L
|
||||
} else {
|
||||
timeInMillis = System.currentTimeMillis()
|
||||
add(Calendar.HOUR_OF_DAY, 8) // Default if re-enabling
|
||||
}
|
||||
}
|
||||
val year = currentCalendar.get(Calendar.YEAR)
|
||||
val month = currentCalendar.get(Calendar.MONTH)
|
||||
val day = currentCalendar.get(Calendar.DAY_OF_MONTH)
|
||||
val hour = currentCalendar.get(Calendar.HOUR_OF_DAY)
|
||||
val minute = currentCalendar.get(Calendar.MINUTE)
|
||||
|
||||
val datePickerDialog =
|
||||
DatePickerDialog(
|
||||
context,
|
||||
{ _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
|
||||
calendar.clear()
|
||||
calendar.set(selectedYear, selectedMonth, selectedDay, hour, minute)
|
||||
waypointInput =
|
||||
waypointInput.copy { expire = (calendar.timeInMillis / 1000).toInt() }
|
||||
},
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
)
|
||||
|
||||
val timePickerDialog =
|
||||
TimePickerDialog(
|
||||
context,
|
||||
{ _: TimePicker, selectedHour: Int, selectedMinute: Int ->
|
||||
// Keep the existing date part
|
||||
val tempCal = Calendar.getInstance()
|
||||
tempCal.timeInMillis = waypointInput.expire * 1000L
|
||||
tempCal.set(Calendar.HOUR_OF_DAY, selectedHour)
|
||||
tempCal.set(Calendar.MINUTE, selectedMinute)
|
||||
waypointInput =
|
||||
waypointInput.copy { expire = (tempCal.timeInMillis / 1000).toInt() }
|
||||
},
|
||||
hour,
|
||||
minute,
|
||||
android.text.format.DateFormat.is24HourFormat(context),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Button(onClick = { datePickerDialog.show() }) { Text(stringResource(R.string.date)) }
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
text = selectedDateString,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Button(onClick = { timePickerDialog.show() }) { Text(stringResource(R.string.time)) }
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
text = selectedTimeString,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
if (waypoint.id != 0) {
|
||||
TextButton(
|
||||
onClick = { onDeleteClicked(waypointInput) },
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f)) // Pushes delete to left and cancel/send to right
|
||||
TextButton(onClick = onDismissRequest, modifier = Modifier.padding(end = 8.dp)) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
Button(onClick = { onSendClicked(waypointInput) }, enabled = waypointInput.name.isNotBlank()) {
|
||||
Text(stringResource(R.string.send))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = null, // Using custom buttons in confirmButton Row
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) { selectedEmoji ->
|
||||
showEmojiPickerView = false
|
||||
waypointInput = waypointInput.copy { icon = selectedEmoji.codePointAt(0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.map.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.geeksville.mesh.ui.map.NodeClusterItem
|
||||
import com.google.maps.android.clustering.Cluster
|
||||
import com.google.maps.android.clustering.view.DefaultClusterRenderer
|
||||
import com.google.maps.android.compose.Circle
|
||||
import com.google.maps.android.compose.MapsComposeExperimentalApi
|
||||
import com.google.maps.android.compose.clustering.Clustering
|
||||
import org.meshtastic.core.ui.component.NodeChip
|
||||
import org.meshtastic.feature.map.BaseMapViewModel
|
||||
|
||||
@OptIn(MapsComposeExperimentalApi::class)
|
||||
@Suppress("NestedBlockDepth")
|
||||
@Composable
|
||||
fun NodeClusterMarkers(
|
||||
nodeClusterItems: List<NodeClusterItem>,
|
||||
mapFilterState: BaseMapViewModel.MapFilterState,
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
onClusterClick: (Cluster<NodeClusterItem>) -> Boolean,
|
||||
) {
|
||||
if (mapFilterState.showPrecisionCircle) {
|
||||
nodeClusterItems.forEach { clusterItem ->
|
||||
key(clusterItem.node.num) {
|
||||
// Add a stable key for each circle
|
||||
clusterItem.getPrecisionMeters()?.let { precisionMeters ->
|
||||
if (precisionMeters > 0) {
|
||||
Circle(
|
||||
center = clusterItem.position,
|
||||
radius = precisionMeters,
|
||||
fillColor = Color(clusterItem.node.colors.second).copy(alpha = 0.2f),
|
||||
strokeColor = Color(clusterItem.node.colors.second),
|
||||
strokeWidth = 2f,
|
||||
zIndex = 1f, // Ensure circles are drawn above markers
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Clustering(
|
||||
items = nodeClusterItems,
|
||||
onClusterClick = onClusterClick,
|
||||
onClusterItemInfoWindowClick = { item ->
|
||||
navigateToNodeDetails(item.node.num)
|
||||
false
|
||||
},
|
||||
clusterItemContent = { clusterItem -> NodeChip(node = clusterItem.node) },
|
||||
onClusterManager = { clusterManager ->
|
||||
(clusterManager.renderer as DefaultClusterRenderer).minClusterSize = 10
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.map.components
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.ui.node.DEG_D
|
||||
import com.google.android.gms.maps.model.BitmapDescriptor
|
||||
import com.google.android.gms.maps.model.LatLng
|
||||
import com.google.maps.android.compose.Marker
|
||||
import com.google.maps.android.compose.rememberUpdatedMarkerState
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.feature.map.BaseMapViewModel
|
||||
|
||||
@Composable
|
||||
fun WaypointMarkers(
|
||||
displayableWaypoints: List<MeshProtos.Waypoint>,
|
||||
mapFilterState: BaseMapViewModel.MapFilterState,
|
||||
myNodeNum: Int,
|
||||
isConnected: Boolean,
|
||||
unicodeEmojiToBitmapProvider: (Int) -> BitmapDescriptor,
|
||||
onEditWaypointRequest: (MeshProtos.Waypoint) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
if (mapFilterState.showWaypoints) {
|
||||
displayableWaypoints.forEach { waypoint ->
|
||||
val markerState =
|
||||
rememberUpdatedMarkerState(position = LatLng(waypoint.latitudeI * DEG_D, waypoint.longitudeI * DEG_D))
|
||||
|
||||
Marker(
|
||||
state = markerState,
|
||||
icon =
|
||||
if (waypoint.icon == 0) {
|
||||
unicodeEmojiToBitmapProvider(PUSHPIN) // Default icon (Round Pushpin)
|
||||
} else {
|
||||
unicodeEmojiToBitmapProvider(waypoint.icon)
|
||||
},
|
||||
title = waypoint.name.replace('\n', ' ').replace('\b', ' '),
|
||||
snippet = waypoint.description.replace('\n', ' ').replace('\b', ' '),
|
||||
visible = true,
|
||||
onInfoWindowClick = {
|
||||
if (waypoint.lockedTo == 0 || waypoint.lockedTo == myNodeNum || !isConnected) {
|
||||
onEditWaypointRequest(waypoint)
|
||||
} else {
|
||||
Toast.makeText(context, context.getString(R.string.locked), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val PUSHPIN = 0x1F4CD // Unicode for Round Pushpin
|
||||
|
|
@ -27,11 +27,9 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.ui.map.MapView
|
||||
import com.geeksville.mesh.ui.map.NodeMapViewModel
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
|
||||
const val DEG_D = 1e-7
|
||||
import org.meshtastic.feature.map.MapView
|
||||
import org.meshtastic.feature.map.node.NodeMapViewModel
|
||||
|
||||
@Composable
|
||||
fun NodeMapScreen(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue