diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9f1f8e51fb..81203ab7bd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -254,6 +254,9 @@ dependencies { googleImplementation(libs.location.services) googleImplementation(libs.play.services.maps) + // MapLibre for F-Droid flavor (POC - additive, does not remove osmdroid yet) + fdroidImplementation("org.maplibre.gl:android-sdk:12.1.0") + fdroidImplementation(libs.osmdroid.android) fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") } diff --git a/app/src/main/java/com/geeksville/mesh/navigation/MapNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/MapNavigation.kt index 5de1c69330..d485282c1a 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/MapNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/MapNavigation.kt @@ -35,7 +35,13 @@ fun NavGraphBuilder.mapGraph(navController: NavHostController) { restoreState = true } }, - navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) }, + navigateToNodeDetails = { + navController.navigate(NodesRoutes.NodeDetailGraph(it)) { + // Don't save the state of the map when navigating to node details + // This ensures pressing back from node detail returns to map, not node list + launchSingleTop = false + } + }, ) } } diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt index 85e0043ef4..a28262f062 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt @@ -97,13 +97,20 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTo ), ), ) { backStackEntry -> - val args = backStackEntry.toRoute() + // Get destNum from the parent graph (NodeDetailGraph) if available, + // otherwise from the route itself (for deep links) + val parentGraphEntry = + remember(backStackEntry) { navController.getBackStackEntry() } + val graphArgs = parentGraphEntry.toRoute() + val routeArgs = backStackEntry.toRoute() + val nodeId = graphArgs.destNum ?: routeArgs.destNum + // When navigating directly to NodeDetail (e.g. from Map or deep link), // we use the Adaptive screen initialized with the specific node ID. AdaptiveNodeListScreen( navController = navController, scrollToTopEvents = scrollToTopEvents, - initialNodeId = args.destNum, + initialNodeId = nodeId, onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) }, ) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt index f4db4c3119..75aa0000d7 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt @@ -67,8 +67,23 @@ fun AdaptiveNodeListScreen( val scope = rememberCoroutineScope() val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange + // Handle back navigation from detail pane BackHandler(enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) { - scope.launch { navigator.navigateBack(backNavigationBehavior) } + if (initialNodeId != null) { + // If opened with initialNodeId (from Map/Connections), always go back to previous screen + navController.navigateUp() + } else { + // Normal navigation within scaffold (when opened from Nodes tab) + scope.launch { navigator.navigateBack(backNavigationBehavior) } + } + } + + // Handle back from list pane when opened with initialNodeId + // This handles the case where user is on list pane after scaffold navigation on tablet + BackHandler( + enabled = initialNodeId != null && navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.List, + ) { + navController.navigateUp() } LaunchedEffect(initialNodeId) { diff --git a/core/prefs/src/fdroid/kotlin/org/meshtastic/core/prefs/di/MapLibreModule.kt b/core/prefs/src/fdroid/kotlin/org/meshtastic/core/prefs/di/MapLibreModule.kt new file mode 100644 index 0000000000..9414453d82 --- /dev/null +++ b/core/prefs/src/fdroid/kotlin/org/meshtastic/core/prefs/di/MapLibreModule.kt @@ -0,0 +1,52 @@ +/* + * 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 . + */ + +package org.meshtastic.core.prefs.di + +import android.content.Context +import android.content.SharedPreferences +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.meshtastic.core.prefs.map.MapLibrePrefs +import org.meshtastic.core.prefs.map.MapLibrePrefsImpl +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class MapLibreSharedPreferences + +@Module +@InstallIn(SingletonComponent::class) +object MapLibreProvidesModule { + @Provides + @Singleton + @MapLibreSharedPreferences + fun provideMapLibreSharedPreferences(@ApplicationContext context: Context): SharedPreferences = + context.getSharedPreferences("maplibre_prefs", Context.MODE_PRIVATE) +} + +@Module +@InstallIn(SingletonComponent::class) +abstract class MapLibreBindsModule { + @Binds @Singleton + abstract fun bindMapLibrePrefs(mapLibrePrefsImpl: MapLibrePrefsImpl): MapLibrePrefs +} diff --git a/core/prefs/src/fdroid/kotlin/org/meshtastic/core/prefs/map/MapLibrePrefs.kt b/core/prefs/src/fdroid/kotlin/org/meshtastic/core/prefs/map/MapLibrePrefs.kt new file mode 100644 index 0000000000..a62d442346 --- /dev/null +++ b/core/prefs/src/fdroid/kotlin/org/meshtastic/core/prefs/map/MapLibrePrefs.kt @@ -0,0 +1,58 @@ +/* + * 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 . + */ + +package org.meshtastic.core.prefs.map + +import android.content.SharedPreferences +import org.meshtastic.core.prefs.DoublePrefDelegate +import org.meshtastic.core.prefs.NullableStringPrefDelegate +import org.meshtastic.core.prefs.PrefDelegate +import org.meshtastic.core.prefs.di.MapLibreSharedPreferences +import javax.inject.Inject +import javax.inject.Singleton + +/** Interface for prefs specific to MapLibre. For general map prefs, see MapPrefs. */ +interface MapLibrePrefs { + var cameraTargetLat: Double + var cameraTargetLng: Double + var cameraZoom: Double + var cameraBearing: Double + var cameraTilt: Double + var shouldRestoreCameraPosition: Boolean + var markerColorMode: String + var clusteringEnabled: Boolean + var heatmapEnabled: Boolean + var baseStyleIndex: Int + var customTileUrl: String? + var usingCustomTiles: Boolean +} + +@Singleton +class MapLibrePrefsImpl @Inject constructor(@MapLibreSharedPreferences prefs: SharedPreferences) : MapLibrePrefs { + override var cameraTargetLat: Double by DoublePrefDelegate(prefs, "camera_target_lat", 0.0) + override var cameraTargetLng: Double by DoublePrefDelegate(prefs, "camera_target_lng", 0.0) + override var cameraZoom: Double by DoublePrefDelegate(prefs, "camera_zoom", 15.0) + override var cameraBearing: Double by DoublePrefDelegate(prefs, "camera_bearing", 0.0) + override var cameraTilt: Double by DoublePrefDelegate(prefs, "camera_tilt", 0.0) + override var shouldRestoreCameraPosition: Boolean by PrefDelegate(prefs, "should_restore_camera_position", false) + override var markerColorMode: String by PrefDelegate(prefs, "marker_color_mode", "NODE") + override var clusteringEnabled: Boolean by PrefDelegate(prefs, "clustering_enabled", true) + override var heatmapEnabled: Boolean by PrefDelegate(prefs, "heatmap_enabled", false) + override var baseStyleIndex: Int by PrefDelegate(prefs, "base_style_index", 0) + override var customTileUrl: String? by NullableStringPrefDelegate(prefs, "custom_tile_url", null) + override var usingCustomTiles: Boolean by PrefDelegate(prefs, "using_custom_tiles", false) +} diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 03d63617ad..98e9a2d90d 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -911,6 +911,12 @@ Add Layer Hide Layer Show Layer + Show role legend + Display a role-to-color legend overlay on the map. + Marker colors + Switch between role colors and node-specific colors. + Role colors + Node colors Remove Layer Add Layer Nodes at this location diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index 3372a7c947..ce458eae3d 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -53,6 +53,9 @@ dependencies { implementation(libs.osmdroid.android) implementation(libs.timber) + // MapLibre for fdroid POC + fdroidImplementation("org.maplibre.gl:android-sdk:12.1.0") + googleImplementation(libs.location.services) googleImplementation(libs.maps.compose) googleImplementation(libs.maps.compose.utils) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 3c1464d061..2f8168d45b 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -19,6 +19,8 @@ package org.meshtastic.feature.map import android.Manifest // Added for Accompanist import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -208,6 +210,163 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) } } +// Dialog helper functions - defined before MapView so they can be called from within it +@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 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 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) Icons.Rounded.Check else null, + onClick = { + selected.value = index + onSelectMapStyle(index) + onDismiss() + }, + ) + } + } +} + +@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: org.osmdroid.views.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), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun PurgeTileSourceDialog(onDismiss: () -> Unit) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val cache = SqlTileWriterExt() + + val sourceList by derivedStateOf { cache.sources.map { it.source as String } } + + val selected = remember { mutableStateListOf() } + + MapsDialog( + title = stringResource(Res.string.map_tile_source), + positiveButton = { + TextButton( + enabled = selected.isNotEmpty(), + onClick = { + selected.forEach { selectedIndex -> + val source = sourceList[selectedIndex] + scope.launch { + context.showToast( + if (cache.purgeCache(source)) { + getString(Res.string.map_purge_success, source) + } else { + getString(Res.string.map_purge_fail) + }, + ) + } + } + + onDismiss() + }, + ) { + Text(text = stringResource(Res.string.clear)) + } + }, + negativeButton = { TextButton(onClick = onDismiss) { Text(text = stringResource(Res.string.cancel)) } }, + onDismiss = onDismiss, + ) { + sourceList.forEachIndexed { index, source -> + val isSelected = selected.contains(index) + BasicListItem( + text = source, + trailingContent = { Checkbox(checked = isSelected, onCheckedChange = {}) }, + onClick = { + if (isSelected) { + selected.remove(index) + } else { + selected.add(index) + } + }, + ) {} + } + } +} + /** * Main composable for displaying the map view, including nodes, waypoints, and user location. It handles user * interactions for map manipulation, filtering, and offline caching. @@ -218,7 +377,12 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) @OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist @Suppress("CyclomaticComplexMethod", "LongMethod") @Composable -fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit) { +fun MapView( + mapViewModel: MapViewModel = hiltViewModel(), + navigateToNodeDetails: (Int) -> Unit, + focusedNodeNum: Int? = null, + nodeTracks: List? = null, +) { var mapFilterExpanded by remember { mutableStateOf(false) } val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() @@ -238,6 +402,8 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: var showCurrentCacheInfo by remember { mutableStateOf(false) } var showPurgeTileSourceDialog by remember { mutableStateOf(false) } var showMapStyleDialog by remember { mutableStateOf(false) } + // Map engine selection: false = osmdroid, true = MapLibre + var useMapLibre by remember { mutableStateOf(true) } val scope = rememberCoroutineScope() val context = LocalContext.current @@ -268,12 +434,17 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: val geoPoints = nodesWithPosition.map { GeoPoint(it.latitude, it.longitude) } BoundingBox.fromGeoPoints(geoPoints) } + // Only create osmdroid map if not using MapLibre val map = - rememberMapViewWithLifecycle( - applicationId = mapViewModel.applicationId, - box = initialCameraView, - tileSource = loadOnlineTileSourceBase(), - ) + if (!useMapLibre) { + rememberMapViewWithLifecycle( + applicationId = mapViewModel.applicationId, + box = initialCameraView, + tileSource = loadOnlineTileSourceBase(), + ) + } else { + null + } val nodeClusterer = remember { RadiusMarkerClusterer(context) } @@ -312,8 +483,8 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: // Effect to toggle MyLocation after permission is granted LaunchedEffect(locationPermissionsState.allPermissionsGranted) { - if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) { - map.toggleMyLocation() + if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission && !useMapLibre) { + map?.toggleMyLocation() triggerLocationToggleAfterPermission = false } } @@ -509,7 +680,10 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: invalidate() } - with(map) { UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values), nodeClusterer) } + // Only update osmdroid markers when using osmdroid + if (!useMapLibre && map != null) { + with(map) { UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values), nodeClusterer) } + } fun MapView.generateBoxOverlay() { overlays.removeAll { it is Polygon } @@ -542,6 +716,7 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: fun startDownload() { val boundingBox = downloadRegionBoundingBox ?: return + val osmdroidMap = map ?: return try { val outputName = buildString { append(Configuration.getInstance().osmdroidBasePath.absolutePath) @@ -549,7 +724,7 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: append("mainFile.sqlite") } val writer = SqliteArchiveTileWriter(outputName) - val cacheManager = CacheManager(map, writer) + val cacheManager = CacheManager(osmdroidMap, writer) cacheManager.downloadAreaAsync( context, boundingBox, @@ -575,371 +750,258 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: Scaffold( floatingActionButton = { - DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { showCacheManagerDialog = true } + // Only show download button for osmdroid + if (!useMapLibre) { + 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 -> 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 { - Column( - modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - MapButton( - onClick = { showMapStyleDialog = true }, - icon = Icons.Outlined.Layers, - contentDescription = Res.string.map_style_selection, + Crossfade( + targetState = useMapLibre, + animationSpec = tween(durationMillis = 300), + label = "MapEngineSwitch", + ) { isMapLibre -> + if (isMapLibre) { + // MapLibre implementation + timber.log.Timber.tag("MapView") + .d( + "Calling MapLibrePOC with focusedNodeNum=%s, nodeTracks count=%d", + focusedNodeNum ?: "null", + nodeTracks?.size ?: 0, + ) + org.meshtastic.feature.map.maplibre.ui.MapLibrePOC( + mapViewModel = mapViewModel, + onNavigateToNodeDetails = navigateToNodeDetails, + focusedNodeNum = focusedNodeNum, + nodeTracks = nodeTracks, ) - Box(modifier = Modifier) { + } else { + // osmdroid implementation + map?.let { osmdroidMap -> + AndroidView( + factory = { + osmdroidMap.apply { + setDestroyMode(false) + addMapListener(boxOverlayListener) + } + }, + modifier = Modifier.fillMaxSize(), + update = { mapView -> mapView.drawOverlays() }, + ) + } ?: Box(modifier = Modifier.fillMaxSize()) + } + } + // Only show osmdroid-specific UI when using osmdroid + if (!useMapLibre) { + 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 { + Column( + modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { MapButton( - onClick = { mapFilterExpanded = true }, - icon = Icons.Outlined.Tune, - contentDescription = Res.string.map_filter, + onClick = { showMapStyleDialog = true }, + icon = Icons.Outlined.Layers, + contentDescription = Res.string.map_style_selection, ) - DropdownMenu( - expanded = mapFilterExpanded, - onDismissRequest = { mapFilterExpanded = false }, - modifier = Modifier.background(MaterialTheme.colorScheme.surface), - ) { - DropdownMenuItem( - text = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Star, - 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() }, + MapButton( + onClick = { useMapLibre = false }, + icon = Icons.Outlined.Layers, + contentDescription = "Switch to osmdroid", + ) + Box(modifier = Modifier) { + MapButton( + onClick = { mapFilterExpanded = true }, + icon = Icons.Outlined.Tune, + contentDescription = Res.string.map_filter, ) - DropdownMenuItem( - text = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.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), - ) - } + DropdownMenu( + expanded = mapFilterExpanded, + onDismissRequest = { mapFilterExpanded = false }, + modifier = Modifier.background(MaterialTheme.colorScheme.surface), + ) { + DropdownMenuItem( + text = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Star, + 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 = Icons.Default.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 = Icons.Default.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() }, + ) + } + } + if (hasGps) { + MapButton( + icon = + if (myLocationOverlay == null) { + Icons.Outlined.MyLocation + } else { + Icons.Default.LocationDisabled }, - onClick = { mapViewModel.toggleShowWaypointsOnMap() }, - ) - DropdownMenuItem( - text = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.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), - ) + contentDescription = stringResource(Res.string.toggle_my_position), + onClick = { + if (locationPermissionsState.allPermissionsGranted) { + map?.toggleMyLocation() + } else { + triggerLocationToggleAfterPermission = true + locationPermissionsState.launchMultiplePermissionRequest() } }, - onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() }, ) } } - if (hasGps) { - MapButton( - icon = - if (myLocationOverlay == null) { - Icons.Outlined.MyLocation - } else { - Icons.Default.LocationDisabled - }, - contentDescription = stringResource(Res.string.toggle_my_position), - ) { - 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 -> - Timber.d("User clicked send waypoint ${waypoint.id}") - showEditWaypointDialog = null - mapViewModel.sendWaypoint( - waypoint.copy { - if (id == 0) id = mapViewModel.generatePacketId() ?: return@EditWaypointDialog - if (name == "") name = "Dropped Pin" - if (expire == 0) expire = Int.MAX_VALUE - lockedTo = if (waypoint.lockedTo != 0) mapViewModel.myNodeNum ?: 0 else 0 - if (waypoint.icon == 0) icon = 128205 + // Only show osmdroid-specific dialogs when using osmdroid + if (!useMapLibre) { + if (showMapStyleDialog) { + MapStyleDialog( + selectedMapStyle = mapViewModel.mapStyleId, + onDismiss = { showMapStyleDialog = false }, + onSelectMapStyle = { styleId: Int -> + mapViewModel.mapStyleId = styleId + map?.setTileSource(loadOnlineTileSourceBase()) }, ) - }, - onDeleteClicked = { waypoint -> - Timber.d("User clicked delete waypoint ${waypoint.id}") - showEditWaypointDialog = null - showDeleteMarkerDialog(waypoint) - }, - onDismissRequest = { - Timber.d("User clicked cancel marker edit dialog") - showEditWaypointDialog = null - }, - ) - } -} - -@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) Icons.Rounded.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), - ), - ) - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@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 } } + if (showCacheManagerDialog) { + CacheManagerDialog( + onClickOption = { option: CacheManagerOption -> + when (option) { + CacheManagerOption.CurrentCacheSize -> { + scope.launch { context.showToast(Res.string.calculating) } + showCurrentCacheInfo = true + } - val selected = remember { mutableStateListOf() } + CacheManagerOption.DownloadRegion -> map?.generateBoxOverlay() - 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) - }, - ) + CacheManagerOption.ClearTiles -> showPurgeTileSourceDialog = true + CacheManagerOption.Cancel -> Unit } - } + showCacheManagerDialog = false + }, + onDismiss = { showCacheManagerDialog = false }, + ) + } - onDismiss() - }, - ) { - Text(text = stringResource(Res.string.clear)) + if (showCurrentCacheInfo && map != null) { + CacheInfoDialog(mapView = map, onDismiss = { showCurrentCacheInfo = false }) + } + + if (showPurgeTileSourceDialog) { + PurgeTileSourceDialog(onDismiss = { showPurgeTileSourceDialog = false }) } - }, - 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, + showEditWaypointDialog?.let { waypoint -> + EditWaypointDialog( + waypoint = waypoint, + onSendClicked = { waypoint -> + Timber.d("User clicked send waypoint ${waypoint.id}") + showEditWaypointDialog = null + mapViewModel.sendWaypoint( + waypoint.copy { + if (id == 0) id = mapViewModel.generatePacketId() ?: return@EditWaypointDialog + if (name == "") name = "Dropped Pin" + if (expire == 0) expire = Int.MAX_VALUE + lockedTo = if (waypoint.lockedTo != 0) mapViewModel.myNodeNum ?: 0 else 0 + if (waypoint.icon == 0) icon = 128205 + }, ) - } - - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { content() } - if (positiveButton != null || negativeButton != null) { - Row(Modifier.align(Alignment.End)) { - positiveButton?.invoke() - negativeButton?.invoke() - } - } - } + }, + onDeleteClicked = { waypoint -> + Timber.d("User clicked delete waypoint ${waypoint.id}") + showEditWaypointDialog = null + showDeleteMarkerDialog(waypoint) + }, + onDismissRequest = { + Timber.d("User clicked cancel marker edit dialog") + showEditWaypointDialog = null + }, + ) } } } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt index 93e5c03bf9..3401b80d82 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -39,6 +39,7 @@ constructor( serviceRepository: ServiceRepository, radioConfigRepository: RadioConfigRepository, buildConfigProvider: BuildConfigProvider, + private val mapLibrePrefs: org.meshtastic.core.prefs.map.MapLibrePrefs, ) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, serviceRepository) { var mapStyleId: Int @@ -56,4 +57,85 @@ constructor( val applicationId = buildConfigProvider.applicationId fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) + + // MapLibre camera position + fun saveCameraPosition(latitude: Double, longitude: Double, zoom: Double, bearing: Double, tilt: Double) { + mapLibrePrefs.cameraTargetLat = latitude + mapLibrePrefs.cameraTargetLng = longitude + mapLibrePrefs.cameraZoom = zoom + mapLibrePrefs.cameraBearing = bearing + mapLibrePrefs.cameraTilt = tilt + // Set flag to restore position on next map open (e.g., returning from node details) + mapLibrePrefs.shouldRestoreCameraPosition = true + } + + fun getCameraPosition(): CameraState? { + // Only restore if flag is set (user is returning from node details) + if (!mapLibrePrefs.shouldRestoreCameraPosition) { + return null + } + + val lat = mapLibrePrefs.cameraTargetLat + val lng = mapLibrePrefs.cameraTargetLng + + // Clear the flag so position is only restored once (on return from node details) + mapLibrePrefs.shouldRestoreCameraPosition = false + + return if (lat != 0.0 || lng != 0.0) { + CameraState( + latitude = lat, + longitude = lng, + zoom = mapLibrePrefs.cameraZoom, + bearing = mapLibrePrefs.cameraBearing, + tilt = mapLibrePrefs.cameraTilt, + ) + } else { + null + } + } + + // Map settings + var markerColorMode: String + get() = mapLibrePrefs.markerColorMode + set(value) { + mapLibrePrefs.markerColorMode = value + } + + var clusteringEnabled: Boolean + get() = mapLibrePrefs.clusteringEnabled + set(value) { + mapLibrePrefs.clusteringEnabled = value + } + + var heatmapEnabled: Boolean + get() = mapLibrePrefs.heatmapEnabled + set(value) { + mapLibrePrefs.heatmapEnabled = value + } + + var baseStyleIndex: Int + get() = mapLibrePrefs.baseStyleIndex + set(value) { + mapLibrePrefs.baseStyleIndex = value + } + + var customTileUrl: String? + get() = mapLibrePrefs.customTileUrl + set(value) { + mapLibrePrefs.customTileUrl = value + } + + var usingCustomTiles: Boolean + get() = mapLibrePrefs.usingCustomTiles + set(value) { + mapLibrePrefs.usingCustomTiles = value + } } + +data class CameraState( + val latitude: Double, + val longitude: Double, + val zoom: Double, + val bearing: Double, + val tilt: Double, +) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt new file mode 100644 index 0000000000..a9ea7cc489 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt @@ -0,0 +1,158 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.component + +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.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +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.Text +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.core.strings.Res +import org.meshtastic.core.strings.add_layer +import org.meshtastic.core.strings.hide_layer +import org.meshtastic.core.strings.manage_map_layers +import org.meshtastic.core.strings.map_layer_formats +import org.meshtastic.core.strings.marker_color_mode_description +import org.meshtastic.core.strings.marker_color_mode_node +import org.meshtastic.core.strings.marker_color_mode_role +import org.meshtastic.core.strings.marker_color_mode_title +import org.meshtastic.core.strings.no_map_layers_loaded +import org.meshtastic.core.strings.remove_layer +import org.meshtastic.core.strings.show_layer +import org.meshtastic.feature.map.MapLayerItem +import org.meshtastic.feature.map.MarkerColorMode + +@Suppress("LongMethod") +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun CustomMapLayersSheet( + mapLayers: List, + onToggleVisibility: (String) -> Unit, + onRemoveLayer: (String) -> Unit, + onAddLayerClicked: () -> Unit, + markerColorMode: MarkerColorMode? = null, + onMarkerColorModeChange: ((MarkerColorMode) -> Unit)? = null, +) { + 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 (markerColorMode != null && onMarkerColorModeChange != null) { + item { + ListItem( + headlineContent = { Text(stringResource(Res.string.marker_color_mode_title)) }, + supportingContent = { Text(stringResource(Res.string.marker_color_mode_description)) }, + trailingContent = { + Button(onClick = { onMarkerColorModeChange(markerColorMode.toggle()) }) { + Text( + text = + stringResource( + if (markerColorMode == MarkerColorMode.ROLE) { + Res.string.marker_color_mode_role + } else { + Res.string.marker_color_mode_node + }, + ), + ) + } + }, + ) + HorizontalDivider() + } + } + + 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 { + IconButton(onClick = { onToggleVisibility(layer.id) }) { + Icon( + imageVector = + if (layer.isVisible) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + }, + contentDescription = + stringResource( + if (layer.isVisible) { + Res.string.hide_layer + } else { + Res.string.show_layer + }, + ), + ) + } + IconButton(onClick = { onRemoveLayer(layer.id) }) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(Res.string.remove_layer), + ) + } + } + }, + ) + HorizontalDivider() + } + } + item { + Button(modifier = Modifier.fillMaxWidth().padding(16.dp), onClick = onAddLayerClicked) { + Text(stringResource(Res.string.add_layer)) + } + } + } +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/MapButton.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/MapButton.kt index 5ced0960df..f8dd74f712 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/MapButton.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/MapButton.kt @@ -20,10 +20,12 @@ package org.meshtastic.feature.map.component import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Layers -import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp @@ -49,9 +51,20 @@ fun MapButton( } @Composable -fun MapButton(icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, onClick: () -> Unit = {}) { - FloatingActionButton(onClick = onClick, modifier = modifier) { - Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(24.dp)) +fun MapButton( + icon: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + iconTint: Color? = null, +) { + FilledIconButton(onClick = onClick, modifier = modifier) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.size(24.dp), + tint = iconTint ?: LocalContentColor.current, + ) } } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/TileCacheManagementSheet.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/TileCacheManagementSheet.kt new file mode 100644 index 0000000000..b465e32724 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/TileCacheManagementSheet.kt @@ -0,0 +1,126 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.component + +import androidx.compose.foundation.layout.Column +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.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +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.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.meshtastic.feature.map.maplibre.utils.MapLibreTileCacheManager +import timber.log.Timber + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun TileCacheManagementSheet(cacheManager: MapLibreTileCacheManager, onDismiss: () -> Unit) { + var isClearing by remember { mutableStateOf(false) } + + LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { + item { + Text(modifier = Modifier.padding(16.dp), text = "Map Cache", style = MaterialTheme.typography.headlineSmall) + HorizontalDivider() + } + + item { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "About Map Caching", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + text = + "Map tiles are automatically cached by MapLibre as you view the map. " + + "This improves performance and allows limited offline viewing of previously visited areas.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + text = "The cache is managed automatically and will be cleared if your device runs low on storage.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HorizontalDivider() + } + + item { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Cache Management", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + text = + "If you're experiencing issues with outdated map tiles or want to free up storage space, you can clear the cache below.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + item { + Button( + modifier = Modifier.fillMaxWidth().padding(16.dp), + onClick = { + isClearing = true + CoroutineScope(Dispatchers.IO).launch { + try { + cacheManager.clearCache() + Timber.tag("TileCacheManagementSheet").d("Cache cleared successfully") + } catch (e: Exception) { + Timber.tag("TileCacheManagementSheet").e(e, "Error clearing cache: ${e.message}") + } finally { + withContext(Dispatchers.Main) { isClearing = false } + } + } + }, + enabled = !isClearing, + ) { + Text(if (isClearing) "Clearing..." else "Clear Map Cache") + } + } + + item { + Text( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + text = "Note: Clearing the cache will require re-downloading tiles as you view the map again.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt new file mode 100644 index 0000000000..08aaddf6ef --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt @@ -0,0 +1,96 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.maplibre + +/** Constants used throughout the MapLibre implementation */ +object MapLibreConstants { + // Coordinate conversion + const val DEG_D = 1e-7 + + // Style URL + const val STYLE_URL = "https://demotiles.maplibre.org/style.json" + + // Source IDs + const val NODES_SOURCE_ID = "meshtastic-nodes-source" + const val NODES_CLUSTER_SOURCE_ID = "meshtastic-nodes-source-clustered" + const val WAYPOINTS_SOURCE_ID = "meshtastic-waypoints-source" + const val TRACK_LINE_SOURCE_ID = "meshtastic-track-line-source" + const val TRACK_POINTS_SOURCE_ID = "meshtastic-track-points-source" + const val HEATMAP_SOURCE_ID = "meshtastic-heatmap-source" + const val OSM_SOURCE_ID = "osm-tiles" + const val NODE_COLOR_PROPERTY = "nodeColor" + const val ROLE_COLOR_PROPERTY = "roleColor" + + // Layer IDs + const val NODES_LAYER_ID = "meshtastic-nodes-layer" // From clustered source, filtered + const val NODE_TEXT_LAYER_ID = "meshtastic-node-text-layer" // From clustered source, filtered + const val NODES_LAYER_NOCLUSTER_ID = "meshtastic-nodes-layer-nocluster" // From non-clustered source + const val NODE_TEXT_LAYER_NOCLUSTER_ID = "meshtastic-node-text-layer-nocluster" // From non-clustered source + const val CLUSTER_CIRCLE_LAYER_ID = "meshtastic-cluster-circle-layer" + const val CLUSTER_COUNT_LAYER_ID = "meshtastic-cluster-count-layer" + const val WAYPOINTS_LAYER_ID = "meshtastic-waypoints-layer" + const val PRECISION_CIRCLE_LAYER_ID = "meshtastic-precision-circle-layer" + const val TRACK_LINE_LAYER_ID = "meshtastic-track-line-layer" + const val TRACK_POINTS_LAYER_ID = "meshtastic-track-points-layer" + const val HEATMAP_LAYER_ID = "meshtastic-heatmap-layer" + const val OSM_LAYER_ID = "osm-layer" + + // Cluster configuration + const val CLUSTER_RADIAL_MAX = 8 + const val CLUSTER_LIST_FETCH_MAX = 200L +} + +/** Base map style options (raster tiles; key-free) */ +enum class BaseMapStyle(val label: String, val urlTemplate: String, val minZoom: Float, val maxZoom: Float) { + OSM_STANDARD( + label = "OSM", + urlTemplate = "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png", + minZoom = 0f, + maxZoom = 19f, + ), + CARTO_LIGHT( + label = "Light", + urlTemplate = "https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", + minZoom = 0f, + maxZoom = 20f, + ), + CARTO_DARK( + label = "Dark", + urlTemplate = "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", + minZoom = 0f, + maxZoom = 20f, + ), + ESRI_SATELLITE( + label = "Satellite", + urlTemplate = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + minZoom = 1f, + maxZoom = 19f, + ), +} + +/** Converts precision bits to meters for accuracy circles */ +fun getPrecisionMeters(precisionBits: Int): Double? { + // Use the same formula as the core UI component for consistency + // Formula: 23905787.925008 * 0.5^bits + // Returns null for invalid precision bits (typically < 10 or > 32) + return if (precisionBits in 10..32) { + org.meshtastic.core.ui.component.precisionBitsToMeters(precisionBits) + } else { + null + } +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt new file mode 100644 index 0000000000..df735d1cbb --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt @@ -0,0 +1,225 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.maplibre.core + +import org.maplibre.android.maps.Style +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection +import org.maplibre.geojson.LineString +import org.maplibre.geojson.Point +import org.meshtastic.core.database.model.Node +import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D +import org.meshtastic.feature.map.maplibre.getPrecisionMeters +import org.meshtastic.feature.map.maplibre.utils.nodeColorHex +import org.meshtastic.feature.map.maplibre.utils.protoShortName +import org.meshtastic.feature.map.maplibre.utils.roleColorHex +import org.meshtastic.feature.map.maplibre.utils.safeSubstring +import org.meshtastic.feature.map.maplibre.utils.shortNameFallback +import org.meshtastic.feature.map.maplibre.utils.stripEmojisForMapLabel +import org.meshtastic.proto.MeshProtos.Position +import org.meshtastic.proto.MeshProtos.Waypoint +import timber.log.Timber + +/** Converts nodes to GeoJSON FeatureCollection JSON string with label selection */ +fun nodesToFeatureCollectionJsonWithSelection(nodes: List, labelNums: Set): String { + val features = + nodes.mapNotNull { node -> + val pos = node.validPosition ?: return@mapNotNull null + val lat = pos.latitudeI * DEG_D + val lon = pos.longitudeI * DEG_D + val short = safeSubstring(protoShortName(node) ?: shortNameFallback(node), 4) + // Strip emojis for MapLibre rendering; if emoji-only, fall back to hex ID + val shortForMap = + stripEmojisForMapLabel(short) + ?: run { + val hex = node.num.toString(16).uppercase() + if (hex.length >= 4) hex.takeLast(4) else hex + } + val shortEsc = escapeJson(shortForMap) + val show = if (labelNums.contains(node.num)) 1 else 0 + val role = node.user.role.name + val roleColor = roleColorHex(node) + val nodeColor = nodeColorHex(node) + val longEsc = escapeJson(node.user.longName ?: "") + val precisionMeters = getPrecisionMeters(pos.precisionBits) ?: 0.0 + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"$longEsc","short":"$shortEsc","role":"$role","roleColor":"$roleColor","nodeColor":"$nodeColor","color":"$roleColor","showLabel":$show,"precisionMeters":$precisionMeters}}""" + } + return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" +} + +/** Converts nodes to GeoJSON FeatureCollection object with label selection */ +fun nodesToFeatureCollectionWithSelectionFC(nodes: List, labelNums: Set): FeatureCollection { + var minLat = Double.POSITIVE_INFINITY + var maxLat = Double.NEGATIVE_INFINITY + var minLon = Double.POSITIVE_INFINITY + var maxLon = Double.NEGATIVE_INFINITY + val features = + nodes.mapNotNull { node -> + val pos = node.validPosition ?: return@mapNotNull null + val lat = pos.latitudeI * DEG_D + val lon = pos.longitudeI * DEG_D + if (lat < minLat) minLat = lat + if (lat > maxLat) maxLat = lat + if (lon < minLon) minLon = lon + if (lon > maxLon) maxLon = lon + val point = Point.fromLngLat(lon, lat) + val f = Feature.fromGeometry(point) + f.addStringProperty("kind", "node") + f.addNumberProperty("num", node.num) + f.addStringProperty("name", node.user.longName ?: "") + val short = safeSubstring(protoShortName(node) ?: shortNameFallback(node), 4) + // Strip emojis for MapLibre rendering; if emoji-only, fall back to hex ID + val shortForMap = + stripEmojisForMapLabel(short) + ?: run { + val hex = node.num.toString(16).uppercase() + if (hex.length >= 4) hex.takeLast(4) else hex + } + f.addStringProperty("short", shortForMap) + f.addStringProperty("role", node.user.role.name) + f.addStringProperty("roleColor", roleColorHex(node)) + f.addStringProperty("nodeColor", nodeColorHex(node)) + f.addStringProperty("color", roleColorHex(node)) + f.addNumberProperty("showLabel", if (labelNums.contains(node.num)) 1 else 0) + val precisionMeters = getPrecisionMeters(pos.precisionBits) ?: 0.0 + f.addNumberProperty("precisionMeters", precisionMeters) + f + } + Timber.tag("MapLibrePOC") + .d("FC bounds: lat=[%.5f, %.5f] lon=[%.5f, %.5f] count=%d", minLat, maxLat, minLon, maxLon, features.size) + return FeatureCollection.fromFeatures(features) +} + +/** Converts waypoints to GeoJSON FeatureCollection */ +fun waypointsToFeatureCollectionFC( + waypoints: Collection, +): FeatureCollection { + val features = + waypoints.mapNotNull { pkt -> + val w: Waypoint = pkt.data.waypoint ?: return@mapNotNull null + val lat = w.latitudeI * DEG_D + val lon = w.longitudeI * DEG_D + if (lat == 0.0 && lon == 0.0) return@mapNotNull null + if (lat !in -90.0..90.0 || lon !in -180.0..180.0) return@mapNotNull null + Feature.fromGeometry(Point.fromLngLat(lon, lat)).also { f -> + f.addStringProperty("kind", "waypoint") + f.addNumberProperty("id", w.id) + f.addStringProperty("name", w.name ?: "Waypoint ${w.id}") + // Convert icon codepoint to emoji string, use 📍 (0x1F4CD) as default + val iconEmoji = + if (w.icon == 0) { + String(Character.toChars(0x1F4CD)) // 📍 Round Pushpin + } else { + String(Character.toChars(w.icon)) + } + f.addStringProperty("icon", iconEmoji) + timber.log.Timber.tag("MapLibrePOC") + .d("Waypoint feature: lat=%.5f, lon=%.5f, icon=%s, name=%s", lat, lon, iconEmoji, w.name) + } + } + timber.log.Timber.tag("MapLibrePOC").d("Created waypoints FC: %d features", features.size) + return FeatureCollection.fromFeatures(features) +} + +/** Safely sets GeoJSON on a source, parsing and validating first */ +fun safeSetGeoJson(style: Style, sourceId: String, json: String) { + try { + val fc = FeatureCollection.fromJson(json) + val count = fc.features()?.size ?: -1 + Timber.tag("MapLibrePOC").d("Setting %s: %d features", sourceId, count) + (style.getSource(sourceId) as? GeoJsonSource)?.setGeoJson(fc) + } catch (e: Throwable) { + Timber.tag("MapLibrePOC").e(e, "Failed to parse/set GeoJSON for %s", sourceId) + } +} + +/** Escapes a string for safe inclusion in JSON */ +fun escapeJson(input: String): String { + val sb = StringBuilder() + for (c in input) { + when (c) { + '"' -> sb.append("\\\"") + '\\' -> sb.append("\\\\") + '\b' -> sb.append("\\b") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + else -> { + if (c < ' ') { + sb.append(String.format("\\u%04x", c.code)) + } else { + sb.append(c) + } + } + } + } + return sb.toString() +} + +/** Converts a list of positions to a GeoJSON LineString for track rendering */ +fun positionsToLineStringFeature(positions: List): Feature? { + if (positions.size < 2) return null + + val points = + positions.map { pos -> + val lat = pos.latitudeI * DEG_D + val lon = pos.longitudeI * DEG_D + Point.fromLngLat(lon, lat) + } + + return Feature.fromGeometry(LineString.fromLngLats(points)) +} + +/** Converts a list of positions to a GeoJSON FeatureCollection for track point markers */ +fun positionsToPointFeatures(positions: List): FeatureCollection { + val features = + positions.mapIndexed { index, pos -> + val lat = pos.latitudeI * DEG_D + val lon = pos.longitudeI * DEG_D + val point = Point.fromLngLat(lon, lat) + val feature = Feature.fromGeometry(point) + + feature.addStringProperty("kind", "track_point") + feature.addNumberProperty("index", index) + feature.addNumberProperty("time", pos.time) + feature.addNumberProperty("altitude", pos.altitude) + feature.addNumberProperty("groundSpeed", pos.groundSpeed) + feature.addNumberProperty("groundTrack", pos.groundTrack) + feature.addNumberProperty("satsInView", pos.satsInView) + feature.addNumberProperty("latitude", lat) + feature.addNumberProperty("longitude", lon) + + feature + } + + return FeatureCollection.fromFeatures(features) +} + +/** Converts nodes to simple GeoJSON FeatureCollection for heatmap */ +fun nodesToHeatmapFeatureCollection(nodes: List): FeatureCollection { + val features = + nodes.mapNotNull { node -> + val pos = node.validPosition ?: return@mapNotNull null + val lat = pos.latitudeI * DEG_D + val lon = pos.longitudeI * DEG_D + val point = Point.fromLngLat(lon, lat) + Feature.fromGeometry(point) + } + return FeatureCollection.fromFeatures(features) +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt new file mode 100644 index 0000000000..50f34bc1bb --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt @@ -0,0 +1,562 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.maplibre.core + +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression.all +import org.maplibre.android.style.expressions.Expression.coalesce +import org.maplibre.android.style.expressions.Expression.eq +import org.maplibre.android.style.expressions.Expression.exponential +import org.maplibre.android.style.expressions.Expression.get +import org.maplibre.android.style.expressions.Expression.gt +import org.maplibre.android.style.expressions.Expression.has +import org.maplibre.android.style.expressions.Expression.interpolate +import org.maplibre.android.style.expressions.Expression.linear +import org.maplibre.android.style.expressions.Expression.literal +import org.maplibre.android.style.expressions.Expression.not +import org.maplibre.android.style.expressions.Expression.product +import org.maplibre.android.style.expressions.Expression.step +import org.maplibre.android.style.expressions.Expression.stop +import org.maplibre.android.style.expressions.Expression.toColor +import org.maplibre.android.style.expressions.Expression.toString +import org.maplibre.android.style.expressions.Expression.zoom +import org.maplibre.android.style.layers.CircleLayer +import org.maplibre.android.style.layers.FillLayer +import org.maplibre.android.style.layers.HeatmapLayer +import org.maplibre.android.style.layers.LineLayer +import org.maplibre.android.style.layers.PropertyFactory.circleColor +import org.maplibre.android.style.layers.PropertyFactory.circleOpacity +import org.maplibre.android.style.layers.PropertyFactory.circlePitchAlignment +import org.maplibre.android.style.layers.PropertyFactory.circleRadius +import org.maplibre.android.style.layers.PropertyFactory.circleStrokeColor +import org.maplibre.android.style.layers.PropertyFactory.circleStrokeWidth +import org.maplibre.android.style.layers.PropertyFactory.fillColor +import org.maplibre.android.style.layers.PropertyFactory.fillOpacity +import org.maplibre.android.style.layers.PropertyFactory.heatmapColor +import org.maplibre.android.style.layers.PropertyFactory.heatmapIntensity +import org.maplibre.android.style.layers.PropertyFactory.heatmapOpacity +import org.maplibre.android.style.layers.PropertyFactory.heatmapRadius +import org.maplibre.android.style.layers.PropertyFactory.heatmapWeight +import org.maplibre.android.style.layers.PropertyFactory.lineColor +import org.maplibre.android.style.layers.PropertyFactory.lineOpacity +import org.maplibre.android.style.layers.PropertyFactory.lineWidth +import org.maplibre.android.style.layers.PropertyFactory.textAllowOverlap +import org.maplibre.android.style.layers.PropertyFactory.textAnchor +import org.maplibre.android.style.layers.PropertyFactory.textColor +import org.maplibre.android.style.layers.PropertyFactory.textField +import org.maplibre.android.style.layers.PropertyFactory.textHaloBlur +import org.maplibre.android.style.layers.PropertyFactory.textHaloColor +import org.maplibre.android.style.layers.PropertyFactory.textHaloWidth +import org.maplibre.android.style.layers.PropertyFactory.textIgnorePlacement +import org.maplibre.android.style.layers.PropertyFactory.textMaxWidth +import org.maplibre.android.style.layers.PropertyFactory.textOffset +import org.maplibre.android.style.layers.PropertyFactory.textSize +import org.maplibre.android.style.layers.PropertyFactory.visibility +import org.maplibre.android.style.layers.SymbolLayer +import org.maplibre.android.style.sources.GeoJsonOptions +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.FeatureCollection +import org.meshtastic.core.database.model.Node +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_COUNT_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.HEATMAP_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.HEATMAP_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_NOCLUSTER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODE_TEXT_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODE_TEXT_LAYER_NOCLUSTER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.OSM_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.OSM_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.PRECISION_CIRCLE_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.TRACK_LINE_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.TRACK_LINE_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.TRACK_POINTS_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.TRACK_POINTS_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_SOURCE_ID +import timber.log.Timber + +/** Ensures all necessary sources and layers exist in the style */ +fun ensureSourcesAndLayers(style: Style) { + Timber.tag("MapLibrePOC") + .d("ensureSourcesAndLayers() begin. Existing layers=%d, sources=%d", style.layers.size, style.sources.size) + + // Add sources if they don't exist + if (style.getSource(NODES_SOURCE_ID) == null) { + style.addSource(GeoJsonSource(NODES_SOURCE_ID, emptyFeatureCollectionJson())) + Timber.tag("MapLibrePOC").d("Added nodes plain GeoJsonSource") + } + if (style.getSource(NODES_CLUSTER_SOURCE_ID) == null) { + val options = + GeoJsonOptions().withCluster(true).withClusterRadius(50).withClusterMaxZoom(14).withClusterMinPoints(2) + style.addSource(GeoJsonSource(NODES_CLUSTER_SOURCE_ID, emptyFeatureCollectionJson(), options)) + Timber.tag("MapLibrePOC").d("Added nodes clustered GeoJsonSource") + } + if (style.getSource(WAYPOINTS_SOURCE_ID) == null) { + style.addSource(GeoJsonSource(WAYPOINTS_SOURCE_ID, emptyFeatureCollectionJson())) + Timber.tag("MapLibrePOC").d("Added waypoints GeoJsonSource") + } + + // Add cluster layers + if (style.getLayer(CLUSTER_CIRCLE_LAYER_ID) == null) { + val clusterLayer = + CircleLayer(CLUSTER_CIRCLE_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties(circleColor("#6D4C41"), circleRadius(14f)) + .withFilter(has("point_count")) + if (style.getLayer(OSM_LAYER_ID) != null) { + style.addLayerAbove(clusterLayer, OSM_LAYER_ID) + } else { + style.addLayer(clusterLayer) + } + Timber.tag("MapLibrePOC").d("Added cluster CircleLayer") + } + if (style.getLayer(CLUSTER_COUNT_LAYER_ID) == null) { + val countLayer = + SymbolLayer(CLUSTER_COUNT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + textField(toString(get("point_count"))), + textColor("#FFFFFF"), + textHaloColor("#000000"), + textHaloWidth(1.5f), + textHaloBlur(0.5f), + textSize(12f), + textAllowOverlap(true), + textIgnorePlacement(true), + ) + .withFilter(has("point_count")) + if (style.getLayer(OSM_LAYER_ID) != null) { + style.addLayerAbove(countLayer, OSM_LAYER_ID) + } else { + style.addLayer(countLayer) + } + Timber.tag("MapLibrePOC").d("Added cluster count SymbolLayer") + } + + // Precision circle layer + if (style.getLayer(PRECISION_CIRCLE_LAYER_ID) == null) { + val layer = + CircleLayer(PRECISION_CIRCLE_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + circleRadius( + interpolate( + exponential(2.0), + zoom(), + stop(0, product(get("precisionMeters"), literal(0.0000025))), + stop(5, product(get("precisionMeters"), literal(0.00008))), + stop(10, product(get("precisionMeters"), literal(0.0025))), + stop(15, product(get("precisionMeters"), literal(0.08))), + stop(18, product(get("precisionMeters"), literal(0.64))), + stop(20, product(get("precisionMeters"), literal(2.56))), + ), + ), + circleColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), + circleOpacity(0.15f), + circleStrokeColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), + circleStrokeWidth(1.5f), + visibility("none"), + ) + .withFilter(all(not(has("point_count")), gt(get("precisionMeters"), literal(0)))) + if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) + Timber.tag("MapLibrePOC").d("Added precision circle layer") + } + + // Node layers (clustered) + if (style.getLayer(NODES_LAYER_ID) == null) { + val layer = + CircleLayer(NODES_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + circleColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), + circleRadius( + interpolate(linear(), zoom(), stop(8, 4f), stop(12, 6f), stop(16, 8f), stop(18, 9.5f)), + ), + circleStrokeColor("#FFFFFF"), + circleStrokeWidth(interpolate(linear(), zoom(), stop(8, 1.5f), stop(12, 2f), stop(16, 2.5f))), + circleOpacity(1.0f), + ) + if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) + Timber.tag("MapLibrePOC").d("Added nodes CircleLayer") + } + if (style.getLayer(NODE_TEXT_LAYER_ID) == null) { + val textLayer = + SymbolLayer(NODE_TEXT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + textField(get("short")), + textColor("#1B1B1B"), + textHaloColor("#FFFFFF"), + textHaloWidth(3.0f), + textHaloBlur(0.7f), + textSize(interpolate(linear(), zoom(), stop(8, 9f), stop(12, 11f), stop(15, 13f), stop(18, 16f))), + textMaxWidth(4f), + textAllowOverlap(step(zoom(), literal(false), stop(11, literal(true)))), + textIgnorePlacement(step(zoom(), literal(false), stop(11, literal(true)))), + textOffset(arrayOf(0f, -1.4f)), + textAnchor("bottom"), + ) + .withFilter(all(not(has("point_count")), eq(get("showLabel"), literal(1)))) + if (style.getLayer(OSM_LAYER_ID) != null) { + style.addLayerAbove(textLayer, OSM_LAYER_ID) + } else { + style.addLayer(textLayer) + } + Timber.tag("MapLibrePOC").d("Added node text SymbolLayer") + } + + // Waypoints layer - small precise marker with red ring and white center + if (style.getLayer(WAYPOINTS_LAYER_ID) == null) { + val layer = + CircleLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID) + .withProperties( + circleColor("#FFFFFF"), // White center for precision + circleRadius(4f), // Small for precision + circleStrokeColor("#FF3B30"), // Red ring + circleStrokeWidth(2f), + circleOpacity(1.0f), + circlePitchAlignment("map"), // Keep aligned to map + ) + if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) + Timber.tag("MapLibrePOC").d("Added waypoints CircleLayer") + } + + Timber.tag("MapLibrePOC") + .d("ensureSourcesAndLayers() end. Layers=%d, Sources=%d", style.layers.size, style.sources.size) + val order = + try { + style.layers.joinToString(" > ") { it.id } + } catch (_: Throwable) { + "" + } + Timber.tag("MapLibrePOC").d("Layer order: %s", order) + logStyleState("ensureSourcesAndLayers(end)", style) +} + +/** Ensures a GeoJSON source and layers exist for an imported map layer */ +fun ensureImportedLayerSourceAndLayers(style: Style, layerId: String, geoJson: String?, isVisible: Boolean) { + val sourceId = "imported-layer-source-$layerId" + val pointLayerId = "imported-layer-points-$layerId" + val lineLayerId = "imported-layer-lines-$layerId" + val fillLayerId = "imported-layer-fills-$layerId" + + Timber.tag("MapLibreLayerManager") + .d( + "ensureImportedLayerSourceAndLayers: layerId=%s, hasGeoJson=%s, isVisible=%s", + layerId, + geoJson != null, + isVisible, + ) + + try { + // Add or update source + val existingSource = style.getSource(sourceId) + if (existingSource == null) { + // Create new source + if (geoJson != null) { + Timber.tag("MapLibreLayerManager") + .d("Creating new GeoJSON source: %s (%d bytes)", sourceId, geoJson.length) + style.addSource(GeoJsonSource(sourceId, geoJson)) + } else { + Timber.tag("MapLibreLayerManager").d("Creating empty GeoJSON source: %s", sourceId) + style.addSource(GeoJsonSource(sourceId, FeatureCollection.fromFeatures(emptyList()))) + } + } else if (geoJson != null && existingSource is GeoJsonSource) { + // Update existing source + Timber.tag("MapLibreLayerManager") + .d("Updating existing GeoJSON source: %s (%d bytes)", sourceId, geoJson.length) + existingSource.setGeoJson(geoJson) + } else { + Timber.tag("MapLibreLayerManager").d("Source already exists: %s", sourceId) + } + + // Add point layer (CircleLayer for points) + if (style.getLayer(pointLayerId) == null) { + Timber.tag("MapLibreLayerManager").d("Creating point layer: %s", pointLayerId) + val pointLayer = CircleLayer(pointLayerId, sourceId) + pointLayer.setProperties( + circleColor("#3388ff"), + circleRadius(5f), + circleOpacity(0.8f), + circleStrokeColor("#ffffff"), + circleStrokeWidth(1f), + visibility(if (isVisible) "visible" else "none"), + ) + style.addLayerAbove(pointLayer, OSM_LAYER_ID) + } else { + Timber.tag("MapLibreLayerManager") + .d("Updating point layer visibility: %s -> %s", pointLayerId, if (isVisible) "visible" else "none") + style.getLayer(pointLayerId)?.setProperties(visibility(if (isVisible) "visible" else "none")) + } + + // Add line layer (LineLayer for LineStrings) + if (style.getLayer(lineLayerId) == null) { + Timber.tag("MapLibreLayerManager").d("Creating line layer: %s", lineLayerId) + val lineLayer = LineLayer(lineLayerId, sourceId) + lineLayer.setProperties( + lineColor("#3388ff"), + lineWidth(2f), + lineOpacity(0.8f), + visibility(if (isVisible) "visible" else "none"), + ) + style.addLayerAbove(lineLayer, OSM_LAYER_ID) + } else { + Timber.tag("MapLibreLayerManager") + .d("Updating line layer visibility: %s -> %s", lineLayerId, if (isVisible) "visible" else "none") + style.getLayer(lineLayerId)?.setProperties(visibility(if (isVisible) "visible" else "none")) + } + + // Add fill layer (FillLayer for Polygons) + if (style.getLayer(fillLayerId) == null) { + Timber.tag("MapLibreLayerManager").d("Creating fill layer: %s", fillLayerId) + val fillLayer = FillLayer(fillLayerId, sourceId) + fillLayer.setProperties( + fillColor("#3388ff"), + fillOpacity(0.3f), + visibility(if (isVisible) "visible" else "none"), + ) + style.addLayerAbove(fillLayer, OSM_LAYER_ID) + } else { + Timber.tag("MapLibreLayerManager") + .d("Updating fill layer visibility: %s -> %s", fillLayerId, if (isVisible) "visible" else "none") + style.getLayer(fillLayerId)?.setProperties(visibility(if (isVisible) "visible" else "none")) + } + + Timber.tag("MapLibreLayerManager").d("Successfully ensured layers for: %s", layerId) + } catch (e: Exception) { + Timber.tag("MapLibreLayerManager").e(e, "Error ensuring imported layer source and layers for $layerId") + } +} + +/** Removes an imported layer's source and layers */ +fun removeImportedLayerSourceAndLayers(style: Style, layerId: String) { + val sourceId = "imported-layer-source-$layerId" + val pointLayerId = "imported-layer-points-$layerId" + val lineLayerId = "imported-layer-lines-$layerId" + val fillLayerId = "imported-layer-fills-$layerId" + + try { + style.getLayer(pointLayerId)?.let { style.removeLayer(it) } + style.getLayer(lineLayerId)?.let { style.removeLayer(it) } + style.getLayer(fillLayerId)?.let { style.removeLayer(it) } + style.getSource(sourceId)?.let { style.removeSource(it) } + } catch (e: Exception) { + Timber.tag("MapLibreLayerManager").e(e, "Error removing imported layer source and layers for $layerId") + } +} + +/** Show/hide cluster layers vs plain nodes based on zoom, density, and toggle */ +fun setClusterVisibilityHysteresis( + map: MapLibreMap, + style: Style, + filteredNodes: List, + enableClusters: Boolean, + currentlyShown: Boolean, + showPrecisionCircle: Boolean = false, +): Boolean { + try { + val zoom = map.cameraPosition.zoom + val showClusters = enableClusters + + // Enforce intended visibility + style.getLayer(CLUSTER_CIRCLE_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) + style.getLayer(CLUSTER_COUNT_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) + + // When clustering is enabled: show clustered source layers (which filter out clusters) + // When clustering is disabled: show non-clustered source layers (which show ALL nodes) + style.getLayer(NODES_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) + style.getLayer(NODE_TEXT_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) + style.getLayer(NODES_LAYER_NOCLUSTER_ID)?.setProperties(visibility(if (showClusters) "none" else "visible")) + style.getLayer(NODE_TEXT_LAYER_NOCLUSTER_ID)?.setProperties(visibility(if (showClusters) "none" else "visible")) + + // Precision circle visibility (always controlled by toggle, independent of clustering) + style + .getLayer(PRECISION_CIRCLE_LAYER_ID) + ?.setProperties(visibility(if (showPrecisionCircle) "visible" else "none")) + + Timber.tag("MapLibrePOC") + .d( + "Node layer visibility: clustered=%s, nocluster=%s, precision=%s", + showClusters, + !showClusters, + showPrecisionCircle, + ) + if (showClusters != currentlyShown) { + Timber.tag("MapLibrePOC").d("Cluster visibility=%s (zoom=%.2f)", showClusters, zoom) + } + return showClusters + } catch (_: Throwable) { + return currentlyShown + } +} + +/** Log current style state: presence and visibility of key layers/sources */ +fun logStyleState(whenTag: String, style: Style) { + try { + val layersToCheck = + listOf( + OSM_LAYER_ID, + CLUSTER_CIRCLE_LAYER_ID, + CLUSTER_COUNT_LAYER_ID, + NODES_LAYER_ID, + NODE_TEXT_LAYER_ID, + NODES_LAYER_NOCLUSTER_ID, + NODE_TEXT_LAYER_NOCLUSTER_ID, + PRECISION_CIRCLE_LAYER_ID, + WAYPOINTS_LAYER_ID, + ) + val sourcesToCheck = listOf(NODES_SOURCE_ID, NODES_CLUSTER_SOURCE_ID, WAYPOINTS_SOURCE_ID, OSM_SOURCE_ID) + val layerStates = + layersToCheck.joinToString(", ") { id -> + val layer = style.getLayer(id) + if (layer == null) "$id=∅" else "$id=${layer.visibility?.value}" + } + val sourceStates = + sourcesToCheck.joinToString(", ") { id -> if (style.getSource(id) == null) "$id=∅" else "$id=✓" } + Timber.tag("MapLibrePOC").d("[%s] layers={%s} sources={%s}", whenTag, layerStates, sourceStates) + } catch (e: Throwable) { + Timber.tag("MapLibrePOC").w(e, "Failed to log style state") + } +} + +/** Manages track line and point sources and layers for node track display */ +fun ensureTrackSourcesAndLayers(style: Style, trackColor: String = "#FF5722") { + // Add track line source if it doesn't exist + if (style.getSource(TRACK_LINE_SOURCE_ID) == null) { + style.addSource(GeoJsonSource(TRACK_LINE_SOURCE_ID, emptyFeatureCollectionJson())) + Timber.tag("MapLibrePOC").d("Added track line GeoJsonSource") + } + + // Add track points source if it doesn't exist + if (style.getSource(TRACK_POINTS_SOURCE_ID) == null) { + style.addSource(GeoJsonSource(TRACK_POINTS_SOURCE_ID, emptyFeatureCollectionJson())) + Timber.tag("MapLibrePOC").d("Added track points GeoJsonSource") + } + + // Add track line layer if it doesn't exist + if (style.getLayer(TRACK_LINE_LAYER_ID) == null) { + val lineLayer = + LineLayer(TRACK_LINE_LAYER_ID, TRACK_LINE_SOURCE_ID) + .withProperties(lineColor(trackColor), lineWidth(3f), lineOpacity(0.8f)) + + // Add above OSM layer if it exists + if (style.getLayer(OSM_LAYER_ID) != null) { + style.addLayerAbove(lineLayer, OSM_LAYER_ID) + } else { + style.addLayer(lineLayer) + } + Timber.tag("MapLibrePOC").d("Added track line LineLayer") + } + + // Add track points layer if it doesn't exist + if (style.getLayer(TRACK_POINTS_LAYER_ID) == null) { + val pointsLayer = + CircleLayer(TRACK_POINTS_LAYER_ID, TRACK_POINTS_SOURCE_ID) + .withProperties( + circleColor(trackColor), + circleRadius(5f), + circleStrokeColor("#FFFFFF"), + circleStrokeWidth(2f), + circleOpacity(0.7f), + ) + + // Add above track line layer + style.addLayerAbove(pointsLayer, TRACK_LINE_LAYER_ID) + Timber.tag("MapLibrePOC").d("Added track points CircleLayer") + } +} + +/** Removes track sources and layers from the style */ +fun removeTrackSourcesAndLayers(style: Style) { + style.getLayer(TRACK_POINTS_LAYER_ID)?.let { style.removeLayer(it) } + style.getLayer(TRACK_LINE_LAYER_ID)?.let { style.removeLayer(it) } + style.getSource(TRACK_POINTS_SOURCE_ID)?.let { style.removeSource(it) } + style.getSource(TRACK_LINE_SOURCE_ID)?.let { style.removeSource(it) } + Timber.tag("MapLibrePOC").d("Removed track sources and layers") +} + +/** Ensures heatmap source and layer exist in the style */ +fun ensureHeatmapSourceAndLayer(style: Style) { + // Add heatmap source if it doesn't exist + if (style.getSource(HEATMAP_SOURCE_ID) == null) { + val emptyFeatureCollection = FeatureCollection.fromFeatures(emptyList()) + val heatmapSource = GeoJsonSource(HEATMAP_SOURCE_ID, emptyFeatureCollection) + style.addSource(heatmapSource) + Timber.tag("MapLibrePOC").d("Added heatmap GeoJsonSource") + } + + // Add heatmap layer if it doesn't exist + if (style.getLayer(HEATMAP_LAYER_ID) == null) { + val heatmapLayer = + HeatmapLayer(HEATMAP_LAYER_ID, HEATMAP_SOURCE_ID) + .withProperties( + // Each node contributes equally to the heatmap + heatmapWeight(literal(1.0)), + // Increase the heatmap intensity by zoom level + // Higher intensity = more sensitive to node density + heatmapIntensity(interpolate(linear(), zoom(), stop(0, 0.3), stop(9, 0.8), stop(15, 1.5))), + // Color ramp for heatmap - requires higher density to reach warmer colors + heatmapColor( + interpolate( + linear(), + literal("heatmap-density"), + stop(0.0, toColor(literal("rgba(33,102,172,0)"))), + stop(0.1, toColor(literal("rgb(33,102,172)"))), + stop(0.3, toColor(literal("rgb(103,169,207)"))), + stop(0.5, toColor(literal("rgb(209,229,240)"))), + stop(0.7, toColor(literal("rgb(253,219,199)"))), + stop(0.85, toColor(literal("rgb(239,138,98)"))), + stop(1.0, toColor(literal("rgb(178,24,43)"))), + ), + ), + // Smaller radius = each node influences a smaller area + // More nodes needed in close proximity to create high density + heatmapRadius(interpolate(linear(), zoom(), stop(0, 2.0), stop(9, 6.0), stop(15, 10.0))), + // Transition from heatmap to circle layer by zoom level + heatmapOpacity(interpolate(linear(), zoom(), stop(7, 1.0), stop(22, 1.0))), + ) + + // Add above OSM layer if it exists, otherwise add at bottom + if (style.getLayer(OSM_LAYER_ID) != null) { + style.addLayerAbove(heatmapLayer, OSM_LAYER_ID) + } else { + style.addLayerAt(heatmapLayer, 0) // Add at bottom + } + Timber.tag("MapLibrePOC").d("Added heatmap HeatmapLayer") + } +} + +/** Removes heatmap source and layer from the style */ +fun removeHeatmapSourceAndLayer(style: Style) { + style.getLayer(HEATMAP_LAYER_ID)?.let { style.removeLayer(it) } + style.getSource(HEATMAP_SOURCE_ID)?.let { style.removeSource(it) } + Timber.tag("MapLibrePOC").d("Removed heatmap source and layer") +} + +/** Toggle visibility of node/cluster/waypoint layers */ +fun setNodeLayersVisibility(style: Style, visible: Boolean) { + val visibilityValue = if (visible) "visible" else "none" + style.getLayer(NODES_LAYER_ID)?.setProperties(visibility(visibilityValue)) + style.getLayer(NODE_TEXT_LAYER_ID)?.setProperties(visibility(visibilityValue)) + style.getLayer(NODES_LAYER_NOCLUSTER_ID)?.setProperties(visibility(visibilityValue)) + style.getLayer(NODE_TEXT_LAYER_NOCLUSTER_ID)?.setProperties(visibility(visibilityValue)) + style.getLayer(CLUSTER_CIRCLE_LAYER_ID)?.setProperties(visibility(visibilityValue)) + style.getLayer(CLUSTER_COUNT_LAYER_ID)?.setProperties(visibility(visibilityValue)) + style.getLayer(WAYPOINTS_LAYER_ID)?.setProperties(visibility(visibilityValue)) + style.getLayer(PRECISION_CIRCLE_LAYER_ID)?.setProperties(visibility(visibilityValue)) + Timber.tag("MapLibrePOC").d("Set node layers visibility to: $visibilityValue") +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLocationManager.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLocationManager.kt new file mode 100644 index 0000000000..2c0b1baa52 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLocationManager.kt @@ -0,0 +1,119 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.maplibre.core + +import android.content.Context +import org.maplibre.android.location.modes.RenderMode +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style +import org.maplibre.android.style.layers.TransitionOptions +import org.maplibre.android.style.sources.GeoJsonSource +import org.meshtastic.core.database.model.Node +import org.meshtastic.feature.map.BaseMapViewModel +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_SOURCE_ID +import org.meshtastic.feature.map.maplibre.utils.applyFilters +import org.meshtastic.feature.map.maplibre.utils.hasAnyLocationPermission +import org.meshtastic.feature.map.maplibre.utils.selectLabelsForViewport +import org.meshtastic.proto.ConfigProtos +import timber.log.Timber + +/** Activates the location component after a style change */ +fun activateLocationComponentForStyle(context: Context, map: MapLibreMap, style: Style) { + try { + if (hasAnyLocationPermission(context)) { + val locationComponent = map.locationComponent + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder(context, style) + .useDefaultLocationEngine(true) + .build(), + ) + locationComponent.isLocationComponentEnabled = true + locationComponent.renderMode = RenderMode.COMPASS + } + } catch (_: SecurityException) { + Timber.tag("MapLibrePOC").w("Location permissions not granted") + } +} + +/** Updates node data sources after filtering or style changes */ +fun updateNodeDataSources( + map: MapLibreMap, + style: Style, + nodes: List, + mapFilterState: BaseMapViewModel.MapFilterState, + enabledRoles: Set, + ourNodeNum: Int?, + isLocationTrackingEnabled: Boolean, + clusteringEnabled: Boolean, + currentClustersShown: Boolean, + density: Float, +): Boolean { + val filtered = applyFilters(nodes, mapFilterState, enabledRoles, ourNodeNum, isLocationTrackingEnabled) + val labelSet = selectLabelsForViewport(map, filtered, density) + val json = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) + safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) + safeSetGeoJson(style, NODES_SOURCE_ID, json) + return setClusterVisibilityHysteresis( + map, + style, + filtered, + clusteringEnabled, + currentClustersShown, + mapFilterState.showPrecisionCircle, + ) +} + +/** Reinitializes style after a style switch (base map change or custom tiles) */ +fun reinitializeStyleAfterSwitch( + context: Context, + map: MapLibreMap, + style: Style, + waypoints: Map, + nodes: List, + mapFilterState: BaseMapViewModel.MapFilterState, + enabledRoles: Set, + ourNodeNum: Int?, + isLocationTrackingEnabled: Boolean, + clusteringEnabled: Boolean, + currentClustersShown: Boolean, + density: Float, +): Boolean { + style.setTransition(TransitionOptions(1000, 0)) + ensureSourcesAndLayers(style) + // Repopulate waypoints + (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + waypointsToFeatureCollectionFC(waypoints.values), + ) + // Re-enable location component + activateLocationComponentForStyle(context, map, style) + // Update node data + return updateNodeDataSources( + map, + style, + nodes, + mapFilterState, + enabledRoles, + ourNodeNum, + isLocationTrackingEnabled, + clusteringEnabled, + currentClustersShown, + density, + ) +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreStyleBuilder.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreStyleBuilder.kt new file mode 100644 index 0000000000..448c0a964d --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreStyleBuilder.kt @@ -0,0 +1,220 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.maplibre.core + +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression.all +import org.maplibre.android.style.expressions.Expression.coalesce +import org.maplibre.android.style.expressions.Expression.eq +import org.maplibre.android.style.expressions.Expression.get +import org.maplibre.android.style.expressions.Expression.has +import org.maplibre.android.style.expressions.Expression.interpolate +import org.maplibre.android.style.expressions.Expression.linear +import org.maplibre.android.style.expressions.Expression.literal +import org.maplibre.android.style.expressions.Expression.not +import org.maplibre.android.style.expressions.Expression.step +import org.maplibre.android.style.expressions.Expression.stop +import org.maplibre.android.style.expressions.Expression.toColor +import org.maplibre.android.style.expressions.Expression.toString +import org.maplibre.android.style.expressions.Expression.zoom +import org.maplibre.android.style.layers.CircleLayer +import org.maplibre.android.style.layers.PropertyFactory.circleColor +import org.maplibre.android.style.layers.PropertyFactory.circleOpacity +import org.maplibre.android.style.layers.PropertyFactory.circleRadius +import org.maplibre.android.style.layers.PropertyFactory.circleStrokeColor +import org.maplibre.android.style.layers.PropertyFactory.circleStrokeWidth +import org.maplibre.android.style.layers.PropertyFactory.rasterOpacity +import org.maplibre.android.style.layers.PropertyFactory.textAllowOverlap +import org.maplibre.android.style.layers.PropertyFactory.textAnchor +import org.maplibre.android.style.layers.PropertyFactory.textColor +import org.maplibre.android.style.layers.PropertyFactory.textField +import org.maplibre.android.style.layers.PropertyFactory.textHaloBlur +import org.maplibre.android.style.layers.PropertyFactory.textHaloColor +import org.maplibre.android.style.layers.PropertyFactory.textHaloWidth +import org.maplibre.android.style.layers.PropertyFactory.textIgnorePlacement +import org.maplibre.android.style.layers.PropertyFactory.textMaxWidth +import org.maplibre.android.style.layers.PropertyFactory.textOffset +import org.maplibre.android.style.layers.PropertyFactory.textSize +import org.maplibre.android.style.layers.PropertyFactory.visibility +import org.maplibre.android.style.layers.RasterLayer +import org.maplibre.android.style.layers.SymbolLayer +import org.maplibre.android.style.sources.GeoJsonOptions +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.android.style.sources.RasterSource +import org.maplibre.android.style.sources.TileSet +import org.meshtastic.feature.map.maplibre.BaseMapStyle +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_COUNT_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_NOCLUSTER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODE_TEXT_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODE_TEXT_LAYER_NOCLUSTER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.OSM_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.OSM_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.ROLE_COLOR_PROPERTY +import org.meshtastic.feature.map.maplibre.MapLibreConstants.STYLE_URL +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_SOURCE_ID + +/** Builds a complete MapLibre style with all necessary sources and layers */ +fun buildMeshtasticStyle(base: BaseMapStyle, customTileUrl: String? = null): Style.Builder { + // Load a complete vector style first (has fonts, glyphs, sprites MapLibre needs) + val tileUrl = customTileUrl ?: base.urlTemplate + val builder = + Style.Builder() + .fromUri(STYLE_URL) + // Add our raster overlay on top + .withSource( + RasterSource( + OSM_SOURCE_ID, + TileSet("osm", tileUrl).apply { + minZoom = base.minZoom + maxZoom = base.maxZoom + }, + 128, + ), + ) + .withLayer(RasterLayer(OSM_LAYER_ID, OSM_SOURCE_ID).withProperties(rasterOpacity(1.0f))) + // Sources + .withSource(GeoJsonSource(NODES_SOURCE_ID, emptyFeatureCollectionJson())) + .withSource( + GeoJsonSource( + NODES_CLUSTER_SOURCE_ID, + emptyFeatureCollectionJson(), + GeoJsonOptions() + .withCluster(true) + .withClusterRadius(50) + .withClusterMaxZoom(14) + .withClusterMinPoints(2) + .withLineMetrics(false) + .withTolerance(0.375f), // Smooth clustering transitions + ), + ) + .withSource(GeoJsonSource(WAYPOINTS_SOURCE_ID, emptyFeatureCollectionJson())) + // Layers - order ensures they are above raster + .withLayer( + CircleLayer(CLUSTER_CIRCLE_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + circleColor("#6D4C41"), + circleRadius(14f), + circleOpacity(1.0f), // Needed for transitions + ) + .withFilter(has("point_count")), + ) + .withLayer( + SymbolLayer(CLUSTER_COUNT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + textField(toString(get("point_count"))), + textColor("#FFFFFF"), + textHaloColor("#000000"), + textHaloWidth(1.5f), + textHaloBlur(0.5f), + textSize(12f), + textAllowOverlap(true), + textIgnorePlacement(true), + ) + .withFilter(has("point_count")), + ) + .withLayer( + CircleLayer(NODES_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + circleColor(defaultMarkerColorExpression()), + circleRadius( + interpolate(linear(), zoom(), stop(8, 4f), stop(12, 6f), stop(16, 8f), stop(18, 9.5f)), + ), + circleStrokeColor("#FFFFFF"), // White border + circleStrokeWidth( + interpolate(linear(), zoom(), stop(8, 1.5f), stop(12, 2f), stop(16, 2.5f), stop(18, 3f)), + ), + circleOpacity(1.0f), // Needed for transitions + ) + .withFilter(not(has("point_count"))), + ) + .withLayer( + SymbolLayer(NODE_TEXT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + textField(get("short")), + textColor("#1B1B1B"), + textHaloColor("#FFFFFF"), + textHaloWidth(3.0f), + textHaloBlur(0.7f), + textSize( + interpolate(linear(), zoom(), stop(8, 9f), stop(12, 11f), stop(15, 13f), stop(18, 16f)), + ), + textMaxWidth(4f), + textAllowOverlap(step(zoom(), literal(false), stop(11, literal(true)))), + textIgnorePlacement(step(zoom(), literal(false), stop(11, literal(true)))), + textOffset(arrayOf(0f, -1.4f)), + textAnchor("bottom"), + ) + .withFilter(all(not(has("point_count")), eq(get("showLabel"), literal(1)))), + ) + // Non-clustered node layers (shown when clustering is disabled) + .withLayer( + CircleLayer(NODES_LAYER_NOCLUSTER_ID, NODES_SOURCE_ID) + .withProperties( + circleColor(defaultMarkerColorExpression()), + circleRadius( + interpolate(linear(), zoom(), stop(8, 4f), stop(12, 6f), stop(16, 8f), stop(18, 9.5f)), + ), + circleStrokeColor("#FFFFFF"), // White border + circleStrokeWidth( + interpolate(linear(), zoom(), stop(8, 1.5f), stop(12, 2f), stop(16, 2.5f), stop(18, 3f)), + ), + circleOpacity(1.0f), // Needed for transitions + visibility("none"), // Hidden by default, shown when clustering disabled + ), + ) + .withLayer( + SymbolLayer(NODE_TEXT_LAYER_NOCLUSTER_ID, NODES_SOURCE_ID) + .withProperties( + textField(get("short")), + textColor("#1B1B1B"), + textHaloColor("#FFFFFF"), + textHaloWidth(3.0f), + textHaloBlur(0.7f), + textSize( + interpolate(linear(), zoom(), stop(8, 9f), stop(12, 11f), stop(15, 13f), stop(18, 16f)), + ), + textMaxWidth(4f), + textAllowOverlap(step(zoom(), literal(false), stop(11, literal(true)))), + textIgnorePlacement(step(zoom(), literal(false), stop(11, literal(true)))), + textOffset(arrayOf(0f, -1.4f)), + textAnchor("bottom"), + visibility("none"), // Hidden by default, shown when clustering disabled + ) + .withFilter(eq(get("showLabel"), literal(1))), + ) + .withLayer( + CircleLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID) + .withProperties( + circleColor("#FFFFFF"), // White center for precision + circleRadius(4f), // Small for precision + circleStrokeColor("#FF3B30"), // Red ring + circleStrokeWidth(2f), + ), + ) + return builder +} + +/** Returns an empty GeoJSON FeatureCollection as a JSON string */ +fun emptyFeatureCollectionJson(): String = """{"type":"FeatureCollection","features":[]}""" + +private fun defaultMarkerColorExpression() = coalesce(toColor(get(ROLE_COLOR_PROPERTY)), toColor(literal("#2E7D32"))) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/CameraIdleHandler.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/CameraIdleHandler.kt new file mode 100644 index 0000000000..024b1f3ba8 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/CameraIdleHandler.kt @@ -0,0 +1,136 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.maplibre.ui + +import android.content.Context +import android.os.SystemClock +import android.view.View +import androidx.compose.runtime.MutableState +import org.maplibre.android.maps.MapLibreMap +import org.meshtastic.core.database.model.Node +import org.meshtastic.feature.map.BaseMapViewModel +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID +import org.meshtastic.feature.map.maplibre.core.logStyleState +import org.meshtastic.feature.map.maplibre.core.nodesToFeatureCollectionJsonWithSelection +import org.meshtastic.feature.map.maplibre.core.safeSetGeoJson +import org.meshtastic.feature.map.maplibre.core.setClusterVisibilityHysteresis +import org.meshtastic.feature.map.maplibre.utils.applyFilters +import org.meshtastic.feature.map.maplibre.utils.selectLabelsForViewport +import org.meshtastic.proto.ConfigProtos +import timber.log.Timber + +private const val CLUSTER_EVAL_DEBOUNCE_MS = 300L + +private var lastClusterEvalMs = 0L + +/** + * Handles camera idle events to manage cluster visibility and label selection. + * + * This handler: + * - Debounces rapid camera movements + * - Filters nodes based on current map state + * - Toggles cluster visibility based on zoom level + * - Selects which nodes should display labels + * - Updates both clustered and non-clustered data sources + * + * @param map The MapLibre map instance + * @param context Android context for density calculations + * @param mapViewRef Reference to the MapView for viewport calculations + * @param nodes All available nodes + * @param mapFilterState Current map filter settings + * @param enabledRoles Set of enabled roles for filtering + * @param ourNode The current device's node info + * @param isLocationTrackingEnabled Whether location tracking is active + * @param heatmapEnabled Whether heatmap mode is active + * @param showingTracksRef Whether track visualization is active + * @param clusteringEnabled Whether clustering is enabled + * @param clustersShownState Mutable state tracking whether clusters are currently shown + */ +fun handleCameraIdle( + map: MapLibreMap, + context: Context, + mapViewRef: View?, + nodes: List, + mapFilterState: BaseMapViewModel.MapFilterState, + enabledRoles: Set, + ourNode: Node?, + isLocationTrackingEnabled: Boolean, + heatmapEnabled: Boolean, + showingTracksRef: MutableState, + clusteringEnabled: Boolean, + clustersShownState: MutableState, +) { + val st = map.style ?: return + + // Skip node updates when heatmap is enabled + if (heatmapEnabled) return + + // Skip node updates when showing tracks + if (showingTracksRef.value) { + Timber.tag("CameraIdleHandler").d("Skipping node updates - showing tracks") + return + } + + // Debounce to avoid rapid toggling during kinetic flings/tiles loading + val now = SystemClock.uptimeMillis() + if (now - lastClusterEvalMs < CLUSTER_EVAL_DEBOUNCE_MS) return + lastClusterEvalMs = now + + val filtered = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) + + Timber.tag("CameraIdleHandler").d("Filtered nodes=%d (of %d)", filtered.size, nodes.size) + + clustersShownState.value = + setClusterVisibilityHysteresis( + map, + st, + filtered, + clusteringEnabled, + clustersShownState.value, + mapFilterState.showPrecisionCircle, + ) + + // Compute which nodes get labels in viewport and update source + val density = context.resources.displayMetrics.density + val labelSet = selectLabelsForViewport(map, filtered, density) + val jsonIdle = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) + + Timber.tag("CameraIdleHandler") + .d( + "Updating sources. labelSet=%d (nums=%s) jsonBytes=%d", + labelSet.size, + labelSet.take(5).joinToString(","), + jsonIdle.length, + ) + + // Update both clustered and non-clustered sources + safeSetGeoJson(st, NODES_CLUSTER_SOURCE_ID, jsonIdle) + safeSetGeoJson(st, NODES_SOURCE_ID, jsonIdle) + logStyleState("onCameraIdle(post-update)", st) + + try { + val w = mapViewRef?.width ?: 0 + val h = mapViewRef?.height ?: 0 + val bbox = android.graphics.RectF(0f, 0f, w.toFloat(), h.toFloat()) + val rendered = map.queryRenderedFeatures(bbox, NODES_LAYER_ID, CLUSTER_CIRCLE_LAYER_ID) + Timber.tag("CameraIdleHandler").d("Rendered features in viewport=%d", rendered.size) + } catch (_: Throwable) {} +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapClickHandlers.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapClickHandlers.kt new file mode 100644 index 0000000000..d9be7e2193 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapClickHandlers.kt @@ -0,0 +1,274 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.maplibre.ui + +import android.content.Context +import android.graphics.PointF +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.geometry.LatLngBounds +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.Point +import org.meshtastic.core.database.entity.Packet +import org.meshtastic.core.database.model.Node +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_NOCLUSTER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_LAYER_ID +import org.meshtastic.proto.MeshProtos.Waypoint +import timber.log.Timber +import kotlin.math.max +import kotlin.math.min + +data class ExpandedCluster(val centerPx: PointF, val members: List) + +// Constants +private const val HIT_BOX_RADIUS_DP = 24f +private const val CLUSTER_LIST_FETCH_MAX = 200 +private const val CLUSTER_RADIAL_MAX = 10 +private const val CLUSTER_OVERLAY_ZOOM_THRESHOLD = 13.5 +private const val MIN_CLUSTER_SPAN_DEGREES = 0.002 +private const val CLUSTER_BOUNDS_PADDING_PX = 96 + +/** + * Handles map click events for cluster expansion, node selection, and waypoint editing. + * + * @param map The MapLibre map instance + * @param context Android context for density calculations + * @param latLng The clicked location + * @param nodes All available nodes + * @param waypoints All available waypoints + * @param onExpandedClusterChange Callback when cluster overlay should be shown/hidden + * @param onClusterListMembersChange Callback when cluster list bottom sheet should be shown/hidden + * @param onSelectedNodeChange Callback when a node is selected + * @param onWaypointEditRequest Callback when a waypoint should be edited + * @return true if the click was handled + */ +fun handleMapClick( + map: MapLibreMap, + context: Context, + latLng: LatLng, + nodes: List, + waypoints: Map, + onExpandedClusterChange: (ExpandedCluster?) -> Unit, + onClusterListMembersChange: (List?) -> Unit, + onSelectedNodeChange: (Int?) -> Unit, + onWaypointEditRequest: (Waypoint?) -> Unit, +): Boolean { + // Any tap on the map clears overlays unless replaced below + onExpandedClusterChange(null) + onClusterListMembersChange(null) + + val screenPoint = map.projection.toScreenLocation(latLng) + // Use a small hitbox to improve taps on small circles + val r = (HIT_BOX_RADIUS_DP * context.resources.displayMetrics.density) + val rect = + android.graphics.RectF( + (screenPoint.x - r).toFloat(), + (screenPoint.y - r).toFloat(), + (screenPoint.x + r).toFloat(), + (screenPoint.y + r).toFloat(), + ) + + val features = + map.queryRenderedFeatures( + rect, + CLUSTER_CIRCLE_LAYER_ID, + NODES_LAYER_ID, + NODES_LAYER_NOCLUSTER_ID, + WAYPOINTS_LAYER_ID, + ) + + Timber.tag("MapClickHandlers") + .d("Map click at (%.5f, %.5f) -> %d features", latLng.latitude, latLng.longitude, features.size) + + val f = features.firstOrNull() + + // If cluster tapped, expand using true cluster leaves from the source + if (f != null && f.hasProperty("point_count")) { + val pointCount = f.getNumberProperty("point_count")?.toInt() ?: 0 + val limit = kotlin.math.min(CLUSTER_LIST_FETCH_MAX.toLong(), pointCount.toLong()) + val src = (map.style?.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource) + + if (src != null) { + val fc = src.getClusterLeaves(f, limit, 0L) + val nums = + fc.features()?.mapNotNull { feat -> + try { + feat.getNumberProperty("num")?.toInt() + } catch (_: Throwable) { + null + } + } ?: emptyList() + + val members = nodes.filter { nums.contains(it.num) } + + if (members.isNotEmpty()) { + val clusterBounds = buildClusterBounds(members) + val zoom = map.cameraPosition?.zoom ?: 0.0 + + if (shouldZoomToBounds(zoom, clusterBounds, members.size)) { + clusterBounds?.let { bounds -> + map.animateCamera(CameraUpdateFactory.newLatLngBounds(bounds.bounds, CLUSTER_BOUNDS_PADDING_PX)) + Timber.tag("MapClickHandlers") + .d( + "Zooming to cluster bounds (zoom=%.2f, latSpan=%.4f, lonSpan=%.4f, members=%d)", + zoom, + bounds.latSpan, + bounds.lonSpan, + members.size, + ) + return true + } + } + + // Center camera on cluster (without zoom) to keep cluster intact + val geom = f.geometry() + if (geom is Point) { + val clusterLat = geom.latitude() + val clusterLon = geom.longitude() + val clusterLatLng = LatLng(clusterLat, clusterLon) + + map.animateCamera( + CameraUpdateFactory.newLatLng(clusterLatLng), + 300, + object : MapLibreMap.CancelableCallback { + override fun onFinish() { + // Calculate screen position AFTER camera animation completes + val clusterCenter = map.projection.toScreenLocation(LatLng(clusterLat, clusterLon)) + + // Set overlay state after camera animation completes + if (pointCount > CLUSTER_RADIAL_MAX) { + // Show list for large clusters + onClusterListMembersChange(members) + } else { + // Show radial overlay for small clusters + onExpandedClusterChange( + ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX)), + ) + } + } + + override fun onCancel() { + // Animation was cancelled, don't show overlay + } + }, + ) + + Timber.tag("MapClickHandlers") + .d("Centering on cluster at (%.5f, %.5f) with %d members", clusterLat, clusterLon, members.size) + } else { + // No geometry, show overlay immediately using current screen position + val clusterCenter = + (f.geometry() as? Point)?.let { p -> + map.projection.toScreenLocation(LatLng(p.latitude(), p.longitude())) + } ?: screenPoint + + if (pointCount > CLUSTER_RADIAL_MAX) { + onClusterListMembersChange(members) + } else { + onExpandedClusterChange(ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX))) + } + } + } + return true + } else { + map.animateCamera(CameraUpdateFactory.zoomIn()) + return true + } + } + + // Handle node/waypoint selection + f?.let { + val kind = it.getStringProperty("kind") + when (kind) { + "node" -> { + val num = it.getNumberProperty("num")?.toInt() ?: -1 + onSelectedNodeChange(num) + + // Center camera on selected node + val geom = it.geometry() + if (geom is Point) { + val nodeLat = geom.latitude() + val nodeLon = geom.longitude() + val nodeLatLng = LatLng(nodeLat, nodeLon) + map.animateCamera(CameraUpdateFactory.newLatLng(nodeLatLng), 300) + Timber.tag("MapClickHandlers").d("Centering on node %d at (%.5f, %.5f)", num, nodeLat, nodeLon) + } + } + "waypoint" -> { + val id = it.getNumberProperty("id")?.toInt() ?: -1 + // Open edit dialog for waypoint + val waypoint = waypoints.values.find { pkt -> pkt.data.waypoint?.id == id }?.data?.waypoint + onWaypointEditRequest(waypoint) + + // Center camera on waypoint + val geom = it.geometry() + if (geom is Point) { + val wpLat = geom.latitude() + val wpLon = geom.longitude() + val wpLatLng = LatLng(wpLat, wpLon) + map.animateCamera(CameraUpdateFactory.newLatLng(wpLatLng), 300) + Timber.tag("MapClickHandlers").d("Centering on waypoint %d at (%.5f, %.5f)", id, wpLat, wpLon) + } + } + else -> {} + } + } + return true +} + +private data class ClusterBounds(val bounds: LatLngBounds, val latSpan: Double, val lonSpan: Double) + +private fun buildClusterBounds(members: List): ClusterBounds? { + val builder = LatLngBounds.Builder() + var included = false + var minLat = Double.MAX_VALUE + var maxLat = -Double.MAX_VALUE + var minLon = Double.MAX_VALUE + var maxLon = -Double.MAX_VALUE + + members.forEach { node -> + node.validPosition?.let { vp -> + val lat = vp.latitudeI * DEG_D + val lon = vp.longitudeI * DEG_D + builder.include(LatLng(lat, lon)) + minLat = min(minLat, lat) + maxLat = max(maxLat, lat) + minLon = min(minLon, lon) + maxLon = max(maxLon, lon) + included = true + } + } + + return if (included) { + ClusterBounds(bounds = builder.build(), latSpan = maxLat - minLat, lonSpan = maxLon - minLon) + } else { + null + } +} + +private fun shouldZoomToBounds(zoom: Double, clusterBounds: ClusterBounds?, memberCount: Int): Boolean { + if (clusterBounds == null || memberCount <= 1) return false + val hasMeaningfulSpread = + clusterBounds.latSpan > MIN_CLUSTER_SPAN_DEGREES || clusterBounds.lonSpan > MIN_CLUSTER_SPAN_DEGREES + return zoom < CLUSTER_OVERLAY_ZOOM_THRESHOLD && hasMeaningfulSpread +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreBottomSheets.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreBottomSheets.kt new file mode 100644 index 0000000000..70c75dc280 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreBottomSheets.kt @@ -0,0 +1,106 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.maplibre.ui + +import androidx.compose.foundation.clickable +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.meshtastic.core.database.model.Node +import org.meshtastic.core.ui.component.NodeChip + +/** Bottom sheet showing details and actions for a selected node */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NodeDetailsBottomSheet( + node: Node, + lastHeardAgo: String, + coords: String, + distanceKm: String?, + onViewFullNode: () -> Unit, + onDismiss: () -> Unit, + sheetState: SheetState, +) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp)) { + NodeChip(node = node) + val longName = node.user.longName + if (!longName.isNullOrBlank()) { + Text( + text = longName, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp), + ) + } + Text(text = "Last heard: $lastHeardAgo", modifier = Modifier.padding(top = 8.dp)) + Text(text = "Coordinates: $coords") + if (distanceKm != null) { + Text(text = "Distance: $distanceKm km") + } + Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp)) { + Button(onClick = onViewFullNode) { Text("View full node") } + } + } + } +} + +/** Bottom sheet showing a list of nodes in a large cluster */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ClusterListBottomSheet( + members: List, + onNodeClicked: (Node) -> Unit, + onDismiss: () -> Unit, + sheetState: SheetState, +) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text(text = "Cluster items (${members.size})", style = MaterialTheme.typography.titleMedium) + LazyColumn { + items(members) { node -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp).clickable { onNodeClicked(node) }, + verticalAlignment = Alignment.CenterVertically, + ) { + NodeChip(node = node, onClick = { onNodeClicked(node) }) + Spacer(modifier = Modifier.width(12.dp)) + val longName = node.user.longName + if (!longName.isNullOrBlank()) { + Text(text = longName, style = MaterialTheme.typography.bodyLarge) + } + } + } + } + } + } +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt new file mode 100644 index 0000000000..85e02c34a2 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt @@ -0,0 +1,279 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.maplibre.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationDisabled +import androidx.compose.material.icons.filled.Navigation +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Layers +import androidx.compose.material.icons.outlined.Map +import androidx.compose.material.icons.outlined.MyLocation +import androidx.compose.material.icons.outlined.Navigation +import androidx.compose.material.icons.outlined.Palette +import androidx.compose.material.icons.outlined.Tune +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.style.sources.GeoJsonSource +import org.meshtastic.core.database.model.Node +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.marker_color_mode_node +import org.meshtastic.core.strings.marker_color_mode_role +import org.meshtastic.core.ui.theme.StatusColors.StatusRed +import org.meshtastic.feature.map.BaseMapViewModel +import org.meshtastic.feature.map.MapViewModel +import org.meshtastic.feature.map.MarkerColorMode +import org.meshtastic.feature.map.component.MapButton +import org.meshtastic.feature.map.maplibre.BaseMapStyle +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID +import org.meshtastic.feature.map.maplibre.core.nodesToFeatureCollectionJsonWithSelection +import org.meshtastic.feature.map.maplibre.utils.applyFilters +import org.meshtastic.proto.ConfigProtos +import timber.log.Timber + +/** Control buttons displayed on the right side of the map */ +@Composable +fun MapLibreControlButtons( + isLocationTrackingEnabled: Boolean, + onLocationTrackingToggle: () -> Unit, + hasLocationPermission: Boolean, + followBearing: Boolean, + onFollowBearingToggle: () -> Unit, + onCompassClick: () -> Unit, + onFilterClick: () -> Unit, + onLegendClick: () -> Unit, + onStyleClick: () -> Unit, + onLayersClick: () -> Unit = {}, + markerColorMode: MarkerColorMode, + onMarkerColorToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier, horizontalAlignment = Alignment.End) { + // Compass button (matches Google Maps style - appears first, rotates with map bearing) + val compassIcon = if (followBearing) Icons.Filled.Navigation else Icons.Outlined.Navigation + MapButton( + onClick = { + if (isLocationTrackingEnabled) { + onFollowBearingToggle() + Timber.tag("MapLibrePOC").d("Follow bearing toggled: %s", !followBearing) + } else { + onCompassClick() + } + }, + icon = compassIcon, + contentDescription = null, + iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { !followBearing }, + ) + + Spacer(modifier = Modifier.size(8.dp)) + + MapButton(onClick = onFilterClick, icon = Icons.Outlined.Tune, contentDescription = null) + + Spacer(modifier = Modifier.size(8.dp)) + + MapButton(onClick = onStyleClick, icon = Icons.Outlined.Map, contentDescription = null) + + Spacer(modifier = Modifier.size(8.dp)) + + MapButton(onClick = onLayersClick, icon = Icons.Outlined.Layers, contentDescription = null) + + Spacer(modifier = Modifier.size(8.dp)) + + // Location tracking button (matches Google Maps style) + if (hasLocationPermission) { + MapButton( + onClick = { + onLocationTrackingToggle() + Timber.tag("MapLibrePOC").d("Location tracking toggled: %s", !isLocationTrackingEnabled) + }, + icon = if (isLocationTrackingEnabled) Icons.Filled.LocationDisabled else Icons.Outlined.MyLocation, + contentDescription = null, + ) + } + + Spacer(modifier = Modifier.size(8.dp)) + + MapButton( + onClick = onMarkerColorToggle, + icon = Icons.Outlined.Palette, + contentDescription = + stringResource( + if (markerColorMode == MarkerColorMode.ROLE) { + Res.string.marker_color_mode_role + } else { + Res.string.marker_color_mode_node + }, + ), + ) + + Spacer(modifier = Modifier.size(8.dp)) + + MapButton(onClick = onLegendClick, icon = Icons.Outlined.Info, contentDescription = null) + } +} + +/** Filter dropdown menu for the map */ +@Composable +fun MapFilterMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + mapFilterState: BaseMapViewModel.MapFilterState, + mapViewModel: MapViewModel, + nodes: List, + enabledRoles: Set, + onRoleToggle: (ConfigProtos.Config.DeviceConfig.Role) -> Unit, + mapRef: MapLibreMap?, + onClusteringToggle: () -> Unit, + clusteringEnabled: Boolean, +) { + DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { + DropdownMenuItem( + text = { Text("Only favorites") }, + onClick = { + mapViewModel.toggleOnlyFavorites() + onDismissRequest() + }, + trailingIcon = { + Checkbox( + checked = mapFilterState.onlyFavorites, + onCheckedChange = { + mapViewModel.toggleOnlyFavorites() + // Refresh both sources when filters change + mapRef?.style?.let { st -> + val filtered = + applyFilters( + nodes, + mapFilterState.copy(onlyFavorites = !mapFilterState.onlyFavorites), + enabledRoles, + ) + (st.getSource(NODES_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + nodesToFeatureCollectionJsonWithSelection(filtered, emptySet()), + ) + (st.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + nodesToFeatureCollectionJsonWithSelection(filtered, emptySet()), + ) + } + }, + ) + }, + ) + DropdownMenuItem( + text = { Text("Show precision circle") }, + onClick = { + mapViewModel.toggleShowPrecisionCircleOnMap() + onDismissRequest() + }, + trailingIcon = { + Checkbox( + checked = mapFilterState.showPrecisionCircle, + onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, + ) + }, + ) + DropdownMenuItem( + text = { Text("Enable clustering") }, + onClick = { + onClusteringToggle() + onDismissRequest() + }, + trailingIcon = { Checkbox(checked = clusteringEnabled, onCheckedChange = { onClusteringToggle() }) }, + ) + HorizontalDivider() + Text( + text = "Roles", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + val roles = nodes.map { it.user.role }.distinct().sortedBy { it.name } + roles.forEach { role -> + val checked = if (enabledRoles.isEmpty()) true else enabledRoles.contains(role) + DropdownMenuItem( + text = { Text(role.name.lowercase().replaceFirstChar { it.uppercase() }) }, + onClick = { onRoleToggle(role) }, + trailingIcon = { Checkbox(checked = checked, onCheckedChange = { onRoleToggle(role) }) }, + ) + } + } +} + +/** Map style selection dropdown menu */ +@Composable +fun MapStyleMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + baseStyles: List, + baseStyleIndex: Int, + usingCustomTiles: Boolean, + customTileUrl: String, + onStyleSelect: (Int) -> Unit, + onCustomTileClick: () -> Unit, +) { + DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { + Text( + text = "Map Style", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + baseStyles.forEachIndexed { index, style -> + DropdownMenuItem( + text = { Text(style.label) }, + onClick = { + onStyleSelect(index) + onDismissRequest() + }, + trailingIcon = { + if (index == baseStyleIndex && !usingCustomTiles) { + Icon(imageVector = Icons.Outlined.Check, contentDescription = "Selected") + } + }, + ) + } + HorizontalDivider() + DropdownMenuItem( + text = { + Text(if (customTileUrl.isEmpty()) "Custom Tile URL..." else "Custom: ${customTileUrl.take(30)}...") + }, + onClick = { + onDismissRequest() + onCustomTileClick() + }, + trailingIcon = { + if (usingCustomTiles && customTileUrl.isNotEmpty()) { + Icon(imageVector = Icons.Outlined.Check, contentDescription = "Selected") + } + }, + ) + } +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreOverlays.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreOverlays.kt new file mode 100644 index 0000000000..1a27e2ac92 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreOverlays.kt @@ -0,0 +1,213 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.maplibre.ui + +import android.graphics.PointF +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SheetState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.maps.MapLibreMap +import org.meshtastic.core.database.model.Node +import org.meshtastic.core.ui.component.NodeChip +import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D +import org.meshtastic.feature.map.maplibre.utils.protoShortName +import org.meshtastic.feature.map.maplibre.utils.roleColor +import org.meshtastic.feature.map.maplibre.utils.shortNameFallback + +/** Expanded cluster overlay showing nodes in a radial pattern */ +@Composable +fun ExpandedClusterOverlay( + centerPx: PointF, + members: List, + density: Float, + onNodeClick: (Node) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.clickable(onClick = onDismiss)) { + val centerX = (centerPx.x / density).dp + val centerY = (centerPx.y / density).dp + val radiusPx = 72f * density + val itemSize = 40.dp + val n = members.size.coerceAtLeast(1) + members.forEachIndexed { idx, node -> + val theta = (2.0 * Math.PI * idx / n) + val x = (centerPx.x + (radiusPx * kotlin.math.cos(theta))).toFloat() + val y = (centerPx.y + (radiusPx * kotlin.math.sin(theta))).toFloat() + val xDp = (x / density).dp + val yDp = (y / density).dp + val label = (protoShortName(node) ?: shortNameFallback(node)).take(4) + val itemHeight = 36.dp + val itemWidth = (40 + label.length * 10).dp + Surface( + modifier = + Modifier.align(Alignment.TopStart) + .offset(x = xDp - itemWidth / 2, y = yDp - itemHeight / 2) + .size(width = itemWidth, height = itemHeight) + .clickable { onNodeClick(node) }, + shape = CircleShape, + color = roleColor(node), + shadowElevation = 6.dp, + ) { + Box(contentAlignment = Alignment.Center) { Text(text = label, color = Color.White, maxLines = 1) } + } + } + } +} + +/** Bottom sheet showing a list of cluster members */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ClusterListBottomSheet( + members: List, + mapRef: MapLibreMap?, + onNodeSelect: (Node) -> Unit, + onDismiss: () -> Unit, + sheetState: SheetState, +) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text(text = "Cluster items (${members.size})", style = MaterialTheme.typography.titleMedium) + LazyColumn { + items(members) { node -> + Row( + modifier = + Modifier.fillMaxWidth().padding(vertical = 6.dp).clickable { + onNodeSelect(node) + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 15.0, + ), + ) + } + }, + verticalAlignment = Alignment.CenterVertically, + ) { + NodeChip( + node = node, + onClick = { + onNodeSelect(node) + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 15.0, + ), + ) + } + }, + ) + Spacer(modifier = Modifier.width(12.dp)) + val longName = node.user.longName + if (!longName.isNullOrBlank()) { + Text(text = longName, style = MaterialTheme.typography.bodyLarge) + } + } + } + } + } + } +} + +/** Legend showing role colors */ +@Composable +fun MapLegend(nodes: List, onDismiss: () -> Unit) { + Surface(modifier = Modifier.padding(16.dp), shape = MaterialTheme.shapes.medium, shadowElevation = 4.dp) { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = "Legend", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.size(8.dp)) + val roles = nodes.map { it.user.role }.distinct().sortedBy { it.name } + roles.forEach { role -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 4.dp)) { + val sampleNode = nodes.first { it.user.role == role } + Surface(modifier = Modifier.size(16.dp), shape = CircleShape, color = roleColor(sampleNode)) {} + Spacer(modifier = Modifier.width(8.dp)) + Text(text = role.name.lowercase().replaceFirstChar { it.uppercase() }) + } + } + Spacer(modifier = Modifier.size(8.dp)) + TextButton(onClick = onDismiss) { Text("Close") } + } + } +} + +/** Dialog for custom tile URL input */ +@Composable +fun CustomTileUrlDialog( + customTileUrlInput: String, + onCustomTileUrlInputChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Custom Tile URL") }, + text = { + Column { + Text( + text = "Enter tile URL with {z}/{x}/{y} placeholders:", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + text = "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp), + ) + OutlinedTextField( + value = customTileUrlInput, + onValueChange = onCustomTileUrlInputChange, + label = { Text("Tile URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { TextButton(onClick = onConfirm) { Text("Apply") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt new file mode 100644 index 0000000000..03af609f28 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -0,0 +1,1306 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.maplibre.ui + +// Import modularized MapLibre components + +import android.annotation.SuppressLint +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.outlined.Map +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import org.maplibre.android.MapLibre +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.location.modes.RenderMode +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression.coalesce +import org.maplibre.android.style.expressions.Expression.get +import org.maplibre.android.style.expressions.Expression.literal +import org.maplibre.android.style.expressions.Expression.toColor +import org.maplibre.android.style.layers.PropertyFactory.circleColor +import org.maplibre.android.style.layers.PropertyFactory.visibility +import org.maplibre.android.style.layers.TransitionOptions +import org.maplibre.android.style.sources.GeoJsonSource +import org.meshtastic.core.database.model.Node +import org.meshtastic.feature.map.LayerType +import org.meshtastic.feature.map.MapLayerItem +import org.meshtastic.feature.map.MapViewModel +import org.meshtastic.feature.map.MarkerColorMode +import org.meshtastic.feature.map.component.CustomMapLayersSheet +import org.meshtastic.feature.map.component.EditWaypointDialog +import org.meshtastic.feature.map.component.TileCacheManagementSheet +import org.meshtastic.feature.map.maplibre.BaseMapStyle +import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D +import org.meshtastic.feature.map.maplibre.MapLibreConstants.HEATMAP_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.HEATMAP_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_NOCLUSTER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODE_COLOR_PROPERTY +import org.meshtastic.feature.map.maplibre.MapLibreConstants.ROLE_COLOR_PROPERTY +import org.meshtastic.feature.map.maplibre.MapLibreConstants.TRACK_LINE_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.TRACK_POINTS_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_SOURCE_ID +import org.meshtastic.feature.map.maplibre.core.activateLocationComponentForStyle +import org.meshtastic.feature.map.maplibre.core.buildMeshtasticStyle +import org.meshtastic.feature.map.maplibre.core.ensureHeatmapSourceAndLayer +import org.meshtastic.feature.map.maplibre.core.ensureImportedLayerSourceAndLayers +import org.meshtastic.feature.map.maplibre.core.ensureSourcesAndLayers +import org.meshtastic.feature.map.maplibre.core.ensureTrackSourcesAndLayers +import org.meshtastic.feature.map.maplibre.core.logStyleState +import org.meshtastic.feature.map.maplibre.core.nodesToFeatureCollectionJsonWithSelection +import org.meshtastic.feature.map.maplibre.core.nodesToHeatmapFeatureCollection +import org.meshtastic.feature.map.maplibre.core.positionsToLineStringFeature +import org.meshtastic.feature.map.maplibre.core.positionsToPointFeatures +import org.meshtastic.feature.map.maplibre.core.reinitializeStyleAfterSwitch +import org.meshtastic.feature.map.maplibre.core.removeImportedLayerSourceAndLayers +import org.meshtastic.feature.map.maplibre.core.removeTrackSourcesAndLayers +import org.meshtastic.feature.map.maplibre.core.safeSetGeoJson +import org.meshtastic.feature.map.maplibre.core.setClusterVisibilityHysteresis +import org.meshtastic.feature.map.maplibre.core.setNodeLayersVisibility +import org.meshtastic.feature.map.maplibre.core.waypointsToFeatureCollectionFC +import org.meshtastic.feature.map.maplibre.utils.MapLibreTileCacheManager +import org.meshtastic.feature.map.maplibre.utils.applyFilters +import org.meshtastic.feature.map.maplibre.utils.copyFileToInternalStorage +import org.meshtastic.feature.map.maplibre.utils.deleteFileFromInternalStorage +import org.meshtastic.feature.map.maplibre.utils.distanceKmBetween +import org.meshtastic.feature.map.maplibre.utils.formatSecondsAgo +import org.meshtastic.feature.map.maplibre.utils.getFileName +import org.meshtastic.feature.map.maplibre.utils.hasAnyLocationPermission +import org.meshtastic.feature.map.maplibre.utils.loadLayerGeoJson +import org.meshtastic.feature.map.maplibre.utils.loadPersistedLayers +import org.meshtastic.feature.map.maplibre.utils.selectLabelsForViewport +import org.meshtastic.proto.ConfigProtos +import org.meshtastic.proto.MeshProtos.Waypoint +import org.meshtastic.proto.copy +import org.meshtastic.proto.waypoint +import timber.log.Timber + +// UI Constants +private const val CAMERA_PADDING_PX = 100 +private const val DEFAULT_ZOOM_LEVEL = 15.0 +private const val SINGLE_TRACK_ZOOM_LEVEL = 12.0 +private const val CLUSTER_EVAL_DEBOUNCE_MS = 300L +private const val HIT_BOX_RADIUS_DP = 24f + +@SuppressLint("MissingPermission") +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun MapLibrePOC( + mapViewModel: MapViewModel = hiltViewModel(), + onNavigateToNodeDetails: (Int) -> Unit = {}, + focusedNodeNum: Int? = null, + nodeTracks: List? = null, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var selectedNodeNum by remember { mutableStateOf(null) } + + val ourNode by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() + val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() + var isLocationTrackingEnabled by remember { mutableStateOf(false) } + var followBearing by remember { mutableStateOf(false) } + var hasLocationPermission by remember { mutableStateOf(false) } + var expandedCluster by remember { mutableStateOf(null) } + var clusterListMembers by remember { mutableStateOf?>(null) } + var mapRef by remember { mutableStateOf(null) } + var styleReady by remember { mutableStateOf(false) } + var mapViewRef by remember { mutableStateOf(null) } + var didInitialCenter by remember { mutableStateOf(false) } + // Track whether we're currently showing tracks (for callback checks) + val showingTracksRef = remember { mutableStateOf(false) } + showingTracksRef.value = nodeTracks != null && focusedNodeNum != null + var markerColorMode by remember { mutableStateOf(MarkerColorMode.NODE) } + var enabledRoles by remember { mutableStateOf>(emptySet()) } + var clusteringEnabled by remember { mutableStateOf(true) } + var editingWaypoint by remember { mutableStateOf(null) } + var mapTypeMenuExpanded by remember { mutableStateOf(false) } + var showCustomTileDialog by remember { mutableStateOf(false) } + var customTileUrl by remember { mutableStateOf("") } + var customTileUrlInput by remember { mutableStateOf("") } + var usingCustomTiles by remember { mutableStateOf(false) } + // Base map style rotation + val baseStyles = remember { enumValues().toList() } + var baseStyleIndex by remember { mutableStateOf(0) } + val baseStyle = baseStyles[baseStyleIndex % baseStyles.size] + // Remember last applied cluster visibility to reduce flashing + val clustersShownState = remember { mutableStateOf(false) } + var clustersShown by clustersShownState + + // Heatmap mode + var heatmapEnabled by remember { mutableStateOf(false) } + + // Map layer management + var mapLayers by remember { mutableStateOf>(emptyList()) } + var showLayersBottomSheet by remember { mutableStateOf(false) } + var layerGeoJsonCache by remember { mutableStateOf>(emptyMap()) } + + // Tile cache management - initialize after MapLibre is initialized + var tileCacheManager by remember { mutableStateOf(null) } + var showCacheBottomSheet by remember { mutableStateOf(false) } + + LaunchedEffect(markerColorMode, styleReady, mapRef) { + val style = mapRef?.style ?: return@LaunchedEffect + if (!styleReady) return@LaunchedEffect + applyMarkerColorMode(style, markerColorMode) + } + + val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() + val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle() + val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() + val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint } + val coroutineScope = rememberCoroutineScope() + + // Check location permission + hasLocationPermission = hasAnyLocationPermission(context) + + // Load persisted map layers on startup + LaunchedEffect(Unit) { mapLayers = loadPersistedLayers(context) } + + // Initialize tile cache manager after MapLibre is initialized + LaunchedEffect(Unit) { + try { + // Ensure MapLibre is initialized first + MapLibre.getInstance(context) + if (tileCacheManager == null) { + tileCacheManager = MapLibreTileCacheManager(context) + Timber.tag("MapLibrePOC").d("Tile cache manager initialized") + } + } catch (e: Exception) { + Timber.tag("MapLibrePOC").e(e, "Failed to initialize tile cache manager") + } + } + + // Helper functions for layer management + fun toggleLayerVisibility(layerId: String) { + mapLayers = + mapLayers.map { + if (it.id == layerId) { + it.copy(isVisible = !it.isVisible) + } else { + it + } + } + } + + fun removeLayer(layerId: String) { + coroutineScope.launch { + val layerToRemove = mapLayers.find { it.id == layerId } + layerToRemove?.uri?.let { uri -> deleteFileFromInternalStorage(uri) } + mapLayers = mapLayers.filterNot { it.id == layerId } + layerGeoJsonCache = layerGeoJsonCache - layerId + } + } + + // File picker launcher for adding map layers + 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) + Timber.tag("MapLibrePOC").d("File picker result: uri=%s, fileName=%s", uri, fileName) + coroutineScope.launch { + val layerName = fileName?.substringBeforeLast('.') ?: "Layer ${mapLayers.size + 1}" + val extension = + fileName?.substringAfterLast('.', "")?.lowercase() + ?: context.contentResolver.getType(uri)?.split('/')?.last() + Timber.tag("MapLibrePOC").d("Layer upload: name=%s, extension=%s", layerName, extension) + 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) { + Timber.tag("MapLibrePOC").d("Detected layer type: %s", layerType) + val finalFileName = fileName ?: "layer_${java.util.UUID.randomUUID()}.$extension" + val localFileUri = copyFileToInternalStorage(context, uri, finalFileName) + if (localFileUri != null) { + Timber.tag("MapLibrePOC").d("File copied to internal storage: %s", localFileUri) + val newItem = MapLayerItem(name = layerName, uri = localFileUri, layerType = layerType) + mapLayers = mapLayers + newItem + Timber.tag("MapLibrePOC").d("Layer added to list. Total layers: %d", mapLayers.size) + } else { + Timber.tag("MapLibrePOC").e("Failed to copy file to internal storage") + } + } else { + Timber.tag("MapLibrePOC").w("Unsupported file type: extension=%s", extension) + } + } + } + } else { + Timber.tag("MapLibrePOC").d("File picker cancelled or failed: resultCode=%d", result.resultCode) + } + } + + fun openFilePicker() { + val intent = + android.content.Intent(android.content.Intent.ACTION_GET_CONTENT).apply { + type = "*/*" + putExtra( + android.content.Intent.EXTRA_MIME_TYPES, + arrayOf( + "application/vnd.google-earth.kml+xml", + "application/vnd.google-earth.kmz", + "application/geo+json", + "application/json", + ), + ) + } + filePickerLauncher.launch(intent) + } + + // Load and render imported map layers + LaunchedEffect(mapLayers, mapRef) { + mapRef?.let { map -> + map.style?.let { style -> + Timber.tag("MapLibrePOC") + .d("Layer rendering LaunchedEffect triggered. Layers count: %d", mapLayers.size) + coroutineScope.launch { + // Load GeoJSON for layers that don't have it cached + mapLayers.forEach { layer -> + if (!layerGeoJsonCache.containsKey(layer.id)) { + Timber.tag("MapLibrePOC") + .d( + "Loading GeoJSON for layer: id=%s, name=%s, type=%s", + layer.id, + layer.name, + layer.layerType, + ) + val geoJson = loadLayerGeoJson(context, layer) + if (geoJson != null) { + val featureCount = + try { + val jsonObj = org.json.JSONObject(geoJson) + jsonObj.optJSONArray("features")?.length() ?: 0 + } catch (e: Exception) { + 0 + } + Timber.tag("MapLibrePOC") + .d( + "GeoJSON loaded for layer %s: %d features, %d bytes", + layer.name, + featureCount, + geoJson.length, + ) + layerGeoJsonCache = layerGeoJsonCache + (layer.id to geoJson) + } else { + Timber.tag("MapLibrePOC").e("Failed to load GeoJSON for layer: %s", layer.name) + } + } else { + Timber.tag("MapLibrePOC").d("Using cached GeoJSON for layer: %s", layer.name) + } + } + + // Ensure all layers are rendered + mapLayers.forEach { layer -> + val geoJson = layerGeoJsonCache[layer.id] + Timber.tag("MapLibrePOC") + .d( + "Rendering layer: id=%s, name=%s, visible=%s, hasGeoJson=%s", + layer.id, + layer.name, + layer.isVisible, + geoJson != null, + ) + ensureImportedLayerSourceAndLayers(style, layer.id, geoJson, layer.isVisible) + } + + // Remove layers that are no longer in the list + val currentLayerIds = mapLayers.map { it.id }.toSet() + val cachedLayerIds = layerGeoJsonCache.keys.toSet() + cachedLayerIds + .filter { it !in currentLayerIds } + .forEach { removedLayerId -> + removeImportedLayerSourceAndLayers(style, removedLayerId) + layerGeoJsonCache = layerGeoJsonCache - removedLayerId + } + } + } + } + } + + // Apply location tracking settings when state changes + LaunchedEffect(isLocationTrackingEnabled, followBearing, hasLocationPermission) { + mapRef?.let { map -> + map.style?.let { style -> + try { + if (hasLocationPermission) { + val locationComponent = map.locationComponent + + // Enable/disable location component based on tracking state + if (isLocationTrackingEnabled) { + // Enable and show location component + if (!locationComponent.isLocationComponentEnabled) { + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder( + context, + style, + ) + .useDefaultLocationEngine(true) + .build(), + ) + locationComponent.isLocationComponentEnabled = true + } + + // Set render mode + locationComponent.renderMode = + if (followBearing) { + RenderMode.COMPASS + } else { + RenderMode.NORMAL + } + + // Set camera mode + locationComponent.cameraMode = + if (followBearing) { + org.maplibre.android.location.modes.CameraMode.TRACKING_COMPASS + } else { + org.maplibre.android.location.modes.CameraMode.TRACKING + } + } else { + // Disable location component to hide the blue dot + if (locationComponent.isLocationComponentEnabled) { + locationComponent.isLocationComponentEnabled = false + } + } + + Timber.tag("MapLibrePOC") + .d( + "Location component updated: enabled=%s, follow=%s", + isLocationTrackingEnabled, + followBearing, + ) + } + } catch (e: Exception) { + Timber.tag("MapLibrePOC").w(e, "Failed to update location component") + } + } + } + } + + // Heatmap mode management + LaunchedEffect( + heatmapEnabled, + nodes, + mapFilterState, + enabledRoles, + ourNode, + isLocationTrackingEnabled, + clusteringEnabled, + nodeTracks, + focusedNodeNum, + ) { + // Don't manage heatmap/clustering when showing tracks + if (nodeTracks != null && focusedNodeNum != null) return@LaunchedEffect + + mapRef?.let { map -> + map.style?.let { style -> + if (heatmapEnabled) { + // Filter nodes same way as regular view + val filteredNodes = + applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) + + // Update heatmap source with filtered node positions + val heatmapFC = nodesToHeatmapFeatureCollection(filteredNodes) + (style.getSource(HEATMAP_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(heatmapFC) + + // Hide node/cluster/waypoint layers + setNodeLayersVisibility(style, false) + + // Show heatmap layer + style.getLayer(HEATMAP_LAYER_ID)?.setProperties(visibility("visible")) + + Timber.tag("MapLibrePOC").d("Heatmap enabled: %d nodes", filteredNodes.size) + } else { + // Hide heatmap layer + style.getLayer(HEATMAP_LAYER_ID)?.setProperties(visibility("none")) + + // Restore proper clustering visibility based on current state + val filteredNodes = + applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) + + // Update node sources with current data + val density = context.resources.displayMetrics.density + val labelSet = selectLabelsForViewport(map, filteredNodes, density) + val json = nodesToFeatureCollectionJsonWithSelection(filteredNodes, labelSet) + safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) + safeSetGeoJson(style, NODES_SOURCE_ID, json) + + clustersShown = + setClusterVisibilityHysteresis( + map, + style, + filteredNodes, + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) + + Timber.tag("MapLibrePOC") + .d("Heatmap disabled, clustering=%b, clustersShown=%b", clusteringEnabled, clustersShown) + } + } + } + } + + // Handle node tracks rendering when nodeTracks or focusedNodeNum changes + LaunchedEffect(nodeTracks, focusedNodeNum, mapFilterState.lastHeardTrackFilter, styleReady) { + if (!styleReady) return@LaunchedEffect + + val map = + mapRef + ?: run { + Timber.tag("MapLibrePOC").w("LaunchedEffect: mapRef is null") + return@LaunchedEffect + } + val style = + map.style + ?: run { + Timber.tag("MapLibrePOC").w("LaunchedEffect: Style not ready yet") + return@LaunchedEffect + } + + map.let { map -> + style.let { style -> + if (nodeTracks != null && focusedNodeNum != null) { + // Ensure track sources and layers exist + ensureTrackSourcesAndLayers(style) + + // Get the focused node to use its color + val focusedNode = nodes.firstOrNull { it.num == focusedNodeNum } + + // Apply time filter + val currentTimeSeconds = System.currentTimeMillis() / 1000 + val filterSeconds = mapFilterState.lastHeardTrackFilter.seconds + val filteredTracks = + nodeTracks.filter { + mapFilterState.lastHeardTrackFilter == org.meshtastic.feature.map.LastHeardFilter.Any || + it.time > currentTimeSeconds - filterSeconds + } + + // Update track line + if (filteredTracks.size >= 2) { + positionsToLineStringFeature(filteredTracks)?.let { lineFeature -> + (style.getSource(TRACK_LINE_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(lineFeature) + } + } + + // Update track points + if (filteredTracks.isNotEmpty()) { + val pointsFC = positionsToPointFeatures(filteredTracks) + (style.getSource(TRACK_POINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(pointsFC) + + // Center camera on the tracks + if (filteredTracks.size == 1) { + // Single position - just center on it with a fixed zoom + val position = filteredTracks.first() + val latLng = + org.maplibre.android.geometry.LatLng( + position.latitudeI * DEG_D, + position.longitudeI * DEG_D, + ) + map.animateCamera(CameraUpdateFactory.newLatLngZoom(latLng, SINGLE_TRACK_ZOOM_LEVEL)) + } else { + // Multiple positions - fit bounds + val trackBounds = org.maplibre.android.geometry.LatLngBounds.Builder() + filteredTracks.forEach { position -> + trackBounds.include( + org.maplibre.android.geometry.LatLng( + position.latitudeI * DEG_D, + position.longitudeI * DEG_D, + ), + ) + } + map.animateCamera( + CameraUpdateFactory.newLatLngBounds(trackBounds.build(), CAMERA_PADDING_PX), + ) + } + } + } else { + removeTrackSourcesAndLayers(style) + } + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { + MapLibre.getInstance(context) + Timber.tag("MapLibrePOC").d("Creating MapView + initializing MapLibre") + MapView(context).apply { + mapViewRef = this + getMapAsync { map -> + mapRef = map + map.applyZoomPreferences(baseStyle) + Timber.tag("MapLibrePOC").d("getMapAsync() map acquired, setting style...") + // Set initial base raster style using MapLibre test-app pattern + map.setStyle(buildMeshtasticStyle(baseStyle)) { style -> + Timber.tag("MapLibrePOC").d("Style loaded (base=%s)", baseStyle.label) + style.setTransition(TransitionOptions(0, 0)) + styleReady = true + logStyleState("after-style-load(pre-ensure)", style) + ensureSourcesAndLayers(style) + ensureHeatmapSourceAndLayer(style) + applyMarkerColorMode(style, markerColorMode) + + // Setup track sources and layers if rendering node tracks + Timber.tag("MapLibrePOC") + .d( + "Track check: nodeTracks=%s (%d positions), focusedNodeNum=%s", + if (nodeTracks != null) "NOT NULL" else "NULL", + nodeTracks?.size ?: 0, + focusedNodeNum ?: "NULL", + ) + + if (nodeTracks != null && focusedNodeNum != null) { + Timber.tag("MapLibrePOC") + .d( + "Loading tracks for node %d, total positions: %d", + focusedNodeNum, + nodeTracks.size, + ) + + // Get the focused node to use its color + val focusedNode = nodes.firstOrNull { it.num == focusedNodeNum } + Timber.tag("MapLibrePOC") + .d( + "Focused node found: %s (searching in %d nodes)", + if (focusedNode != null) "YES" else "NO", + nodes.size, + ) + + val trackColor = + focusedNode?.let { String.format("#%06X", 0xFFFFFF and it.colors.second) } + ?: "#FF5722" // Default orange color + + Timber.tag("MapLibrePOC").d("Track color: %s", trackColor) + + ensureTrackSourcesAndLayers(style, trackColor) + Timber.tag("MapLibrePOC").d("Track sources and layers ensured") + + // Filter tracks by time using lastHeardTrackFilter + val currentTimeSeconds = System.currentTimeMillis() / 1000 + val filterSeconds = mapFilterState.lastHeardTrackFilter.seconds + Timber.tag("MapLibrePOC") + .d( + "Filtering tracks - filter: %s (seconds: %d), current time: %d", + mapFilterState.lastHeardTrackFilter, + filterSeconds, + currentTimeSeconds, + ) + + val filteredTracks = + nodeTracks + .filter { + val keep = + mapFilterState.lastHeardTrackFilter == + org.meshtastic.feature.map.LastHeardFilter.Any || + it.time > currentTimeSeconds - filterSeconds + if (!keep) { + Timber.tag("MapLibrePOC") + .v( + "Filtering out position at time %d (age: %d seconds)", + it.time, + currentTimeSeconds - it.time, + ) + } + keep + } + .sortedBy { it.time } + + Timber.tag("MapLibrePOC") + .d( + "Tracks filtered: %d positions remain (from %d total)", + filteredTracks.size, + nodeTracks.size, + ) + + // Update track line + if (filteredTracks.size >= 2) { + Timber.tag("MapLibrePOC") + .d("Creating line feature from %d points", filteredTracks.size) + positionsToLineStringFeature(filteredTracks)?.let { lineFeature -> + Timber.tag("MapLibrePOC").d("Setting line feature on source") + (style.getSource(TRACK_LINE_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + lineFeature, + ) + Timber.tag("MapLibrePOC").d("Track line set successfully") + } + ?: run { + Timber.tag("MapLibrePOC") + .w( + "Failed to create line feature - positionsToLineStringFeature returned null", + ) + } + } else { + Timber.tag("MapLibrePOC") + .w("Not enough points for track line (need >=2, have %d)", filteredTracks.size) + } + + // Update track points + if (filteredTracks.isNotEmpty()) { + Timber.tag("MapLibrePOC") + .d("Creating point features from %d points", filteredTracks.size) + val pointsFC = positionsToPointFeatures(filteredTracks) + (style.getSource(TRACK_POINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(pointsFC) + Timber.tag("MapLibrePOC").d("Track points set successfully") + } else { + Timber.tag("MapLibrePOC").w("No filtered tracks to display as points") + } + + Timber.tag("MapLibrePOC") + .i( + "✓ Track rendering complete: %d positions displayed for node %d", + filteredTracks.size, + focusedNodeNum, + ) + + // Center camera on the tracks + if (filteredTracks.isNotEmpty()) { + val trackBounds = org.maplibre.android.geometry.LatLngBounds.Builder() + filteredTracks.forEach { position -> + trackBounds.include( + org.maplibre.android.geometry.LatLng( + position.latitudeI * DEG_D, + position.longitudeI * DEG_D, + ), + ) + } + val padding = 100 // pixels + map.animateCamera(CameraUpdateFactory.newLatLngBounds(trackBounds.build(), padding)) + Timber.tag("MapLibrePOC") + .d("Camera centered on %d track positions", filteredTracks.size) + } + } else { + Timber.tag("MapLibrePOC").d("No tracks to display - removing track layers") + // Remove track layers if no tracks to display + removeTrackSourcesAndLayers(style) + } + + // Push current data immediately after style load + try { + // Only set node data if we're not showing tracks + if (nodeTracks == null || focusedNodeNum == null) { + val density = context.resources.displayMetrics.density + val filteredNodes = + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ) + val labelSet = selectLabelsForViewport(map, filteredNodes, density) + (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + waypointsToFeatureCollectionFC(waypoints.values), + ) + // Set clustered source only (like MapLibre example) + val json = nodesToFeatureCollectionJsonWithSelection(filteredNodes, labelSet) + Timber.tag("MapLibrePOC") + .d("Setting nodes sources: %d nodes, jsonBytes=%d", nodes.size, json.length) + safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) + safeSetGeoJson(style, NODES_SOURCE_ID, json) // Also populate non-clustered source + Timber.tag("MapLibrePOC") + .d( + "Initial data set after style load. nodes=%d waypoints=%d", + nodes.size, + waypoints.size, + ) + logStyleState("after-style-load(post-sources)", style) + } else { + Timber.tag("MapLibrePOC").d("Skipping node data setup - showing tracks instead") + } + } catch (t: Throwable) { + Timber.tag("MapLibrePOC").e(t, "Failed to set initial data after style load") + } + // Keep base vector layers; OSM raster will sit below node layers for labels/roads + // Enable location component (if permissions granted) + activateLocationComponentForStyle(context, map, style) + Timber.tag("MapLibrePOC").d("Location component initialization attempted") + // Initial center on user's device location if available, else our node + if (!didInitialCenter) { + try { + val loc = map.locationComponent.lastKnownLocation + if (loc != null) { + val target = LatLng(loc.latitude, loc.longitude) + map.animateCamera(CameraUpdateFactory.newLatLngZoom(target, 10.0)) + didInitialCenter = true + } else { + ourNode?.validPosition?.let { p -> + map.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 10.0, + ), + ) + didInitialCenter = true + } + ?: run { + // Fallback: center to bounds of current nodes if available + val filtered = + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ) + val boundsBuilder = org.maplibre.android.geometry.LatLngBounds.Builder() + var any = false + filtered.forEach { n -> + n.validPosition?.let { vp -> + boundsBuilder.include( + LatLng(vp.latitudeI * DEG_D, vp.longitudeI * DEG_D), + ) + any = true + } + } + if (any) { + val b = boundsBuilder.build() + map.animateCamera(CameraUpdateFactory.newLatLngBounds(b, 64)) + } else { + map.moveCamera( + CameraUpdateFactory.newLatLngZoom(LatLng(0.0, 0.0), 2.5), + ) + } + didInitialCenter = true + } + } + } catch (_: Throwable) {} + } + map.addOnMapClickListener { latLng -> + handleMapClick( + map = map, + context = context, + latLng = latLng, + nodes = nodes, + waypoints = waypoints, + onExpandedClusterChange = { expandedCluster = it }, + onClusterListMembersChange = { clusterListMembers = it }, + onSelectedNodeChange = { selectedNodeNum = it }, + onWaypointEditRequest = { editingWaypoint = it }, + ) + } + // Long-press to create waypoint + map.addOnMapLongClickListener { latLng -> + if (isConnected) { + val newWaypoint = waypoint { + latitudeI = (latLng.latitude / DEG_D).toInt() + longitudeI = (latLng.longitude / DEG_D).toInt() + } + editingWaypoint = newWaypoint + Timber.tag("MapLibrePOC") + .d("Long press created waypoint at ${latLng.latitude}, ${latLng.longitude}") + } + true + } + // Update clustering visibility on camera idle (zoom changes) + map.addOnCameraIdleListener { + handleCameraIdle( + map = map, + context = context, + mapViewRef = mapViewRef, + nodes = nodes, + mapFilterState = mapFilterState, + enabledRoles = enabledRoles, + ourNode = ourNode, + isLocationTrackingEnabled = isLocationTrackingEnabled, + heatmapEnabled = heatmapEnabled, + showingTracksRef = showingTracksRef, + clusteringEnabled = clusteringEnabled, + clustersShownState = clustersShownState, + ) + } + // Hide expanded cluster overlay whenever camera moves to avoid stale screen positions + map.addOnCameraMoveListener { + if (expandedCluster != null || clusterListMembers != null) { + expandedCluster = null + clusterListMembers = null + } + } + } + } + } + }, + update = { mapView: MapView -> + mapView.getMapAsync { map -> + val style = map.style + if (style == null) { + Timber.tag("MapLibrePOC").w("Style not yet available in update()") + return@getMapAsync + } + // Apply bearing render mode toggle + try { + map.locationComponent.renderMode = if (followBearing) RenderMode.COMPASS else RenderMode.NORMAL + } catch (_: Throwable) { + /* ignore */ + } + + // Handle location tracking state changes + if (isLocationTrackingEnabled && hasAnyLocationPermission(context)) { + try { + val locationComponent = map.locationComponent + if (!locationComponent.isLocationComponentEnabled) { + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder( + context, + map.style!!, + ) + .useDefaultLocationEngine(true) + .build(), + ) + locationComponent.isLocationComponentEnabled = true + } + locationComponent.renderMode = if (followBearing) RenderMode.COMPASS else RenderMode.NORMAL + locationComponent.cameraMode = + if (isLocationTrackingEnabled) { + if (followBearing) { + org.maplibre.android.location.modes.CameraMode.TRACKING_COMPASS + } else { + org.maplibre.android.location.modes.CameraMode.TRACKING + } + } else { + org.maplibre.android.location.modes.CameraMode.NONE + } + Timber.tag("MapLibrePOC") + .d( + "Location tracking: enabled=%s, follow=%s, mode=%s", + isLocationTrackingEnabled, + followBearing, + locationComponent.cameraMode, + ) + } catch (e: Exception) { + Timber.tag("MapLibrePOC").w(e, "Failed to update location component") + } + } + // Skip node updates when heatmap is enabled + if (!heatmapEnabled) { + Timber.tag("MapLibrePOC") + .d("Updating sources. nodes=%d, waypoints=%d", nodes.size, waypoints.size) + val density = context.resources.displayMetrics.density + val labelSet = selectLabelsForViewport(map, nodes, density) + (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + waypointsToFeatureCollectionFC(waypoints.values), + ) + val filteredNow = + applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) + val jsonNow = nodesToFeatureCollectionJsonWithSelection(filteredNow, labelSet) + safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, jsonNow) + safeSetGeoJson(style, NODES_SOURCE_ID, jsonNow) // Also populate non-clustered source + // Apply visibility now + clustersShown = + setClusterVisibilityHysteresis( + map, + style, + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ), + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) + } + logStyleState("update(block)", style) + } + }, + ) + + // Role legend (based on roles present in current nodes) + if (markerColorMode == MarkerColorMode.ROLE) { + RoleLegend(nodes = nodes, modifier = Modifier.align(Alignment.BottomStart).padding(12.dp)) + } + + // Map controls: horizontal toolbar at the top (matches Google Maps style) + MapToolbar( + hasLocationPermission = hasLocationPermission, + isLocationTrackingEnabled = isLocationTrackingEnabled, + followBearing = followBearing, + onLocationTrackingChanged = { enabled, follow -> + isLocationTrackingEnabled = enabled + followBearing = follow + }, + mapFilterState = mapFilterState, + onToggleOnlyFavorites = { + mapViewModel.toggleOnlyFavorites() + // Refresh both sources when filters change + mapRef?.style?.let { st -> + val filtered = + applyFilters( + nodes, + mapFilterState.copy(onlyFavorites = !mapFilterState.onlyFavorites), + enabledRoles, + ) + (st.getSource(NODES_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + nodesToFeatureCollectionJsonWithSelection(filtered, emptySet()), + ) + (st.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + nodesToFeatureCollectionJsonWithSelection(filtered, emptySet()), + ) + } + }, + onToggleShowPrecisionCircle = { mapViewModel.toggleShowPrecisionCircleOnMap() }, + nodes = nodes, + enabledRoles = enabledRoles, + onRoleToggled = { role -> + enabledRoles = + if (enabledRoles.isEmpty()) { + setOf(role) + } else if (enabledRoles.contains(role)) { + enabledRoles - role + } else { + enabledRoles + role + } + mapRef?.style?.let { st -> + mapRef?.let { map -> + clustersShown = + setClusterVisibilityHysteresis( + map, + st, + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ), + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) + } + } + }, + clusteringEnabled = clusteringEnabled, + onClusteringToggled = { enabled -> + clusteringEnabled = enabled + mapRef?.style?.let { st -> + mapRef?.let { map -> + clustersShown = + setClusterVisibilityHysteresis( + map, + st, + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ), + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) + } + } + }, + baseStyles = baseStyles, + baseStyleIndex = baseStyleIndex, + usingCustomTiles = usingCustomTiles, + onStyleSelected = { index -> + baseStyleIndex = index + usingCustomTiles = false + val next = baseStyles[baseStyleIndex % baseStyles.size] + mapRef?.let { map -> + Timber.tag("MapLibrePOC").d("Switching base style to: %s", next.label) + map.applyZoomPreferences(next) + map.setStyle(buildMeshtasticStyle(next)) { st -> + Timber.tag("MapLibrePOC").d("Base map switched to: %s", next.label) + val density = context.resources.displayMetrics.density + clustersShown = + reinitializeStyleAfterSwitch( + context, + map, + st, + waypoints, + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + clusteringEnabled, + clustersShown, + density, + ) + applyMarkerColorMode(st, markerColorMode) + } + } + }, + customTileUrl = customTileUrl, + onCustomTileClicked = { + customTileUrlInput = customTileUrl + showCustomTileDialog = true + }, + onShowLayersClicked = { showLayersBottomSheet = true }, + onShowCacheClicked = { showCacheBottomSheet = true }, + markerColorMode = markerColorMode, + onMarkerColorModeToggle = { markerColorMode = markerColorMode.toggle() }, + heatmapEnabled = heatmapEnabled, + onHeatmapToggled = { heatmapEnabled = !heatmapEnabled }, + modifier = Modifier.align(Alignment.TopCenter).padding(top = 16.dp), + ) + + // Zoom controls (bottom right) + ZoomControls( + mapRef = mapRef, + modifier = Modifier.align(Alignment.BottomEnd).padding(bottom = 16.dp, end = 16.dp), + ) + + // Custom tile URL dialog + if (showCustomTileDialog) { + CustomTileDialog( + customTileUrlInput = customTileUrlInput, + onCustomTileUrlInputChanged = { customTileUrlInput = it }, + onApply = { + customTileUrl = customTileUrlInput.trim() + if (customTileUrl.isNotEmpty()) { + usingCustomTiles = true + // Apply custom tiles (use first base style as template but we'll override the raster source) + mapRef?.let { map -> + Timber.tag("MapLibrePOC").d("Switching to custom tiles: %s", customTileUrl) + val templateStyle = baseStyles[baseStyleIndex % baseStyles.size] + map.applyZoomPreferences(templateStyle) + map.setStyle(buildMeshtasticStyle(templateStyle, customTileUrl)) { st -> + Timber.tag("MapLibrePOC").d("Custom tiles applied") + val density = context.resources.displayMetrics.density + clustersShown = + reinitializeStyleAfterSwitch( + context, + map, + st, + waypoints, + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + clusteringEnabled, + clustersShown, + density, + ) + applyMarkerColorMode(st, markerColorMode) + } + } + } + showCustomTileDialog = false + }, + onDismiss = { showCustomTileDialog = false }, + ) + } + + // Expanded cluster radial overlay + expandedCluster?.let { ec -> + val d = context.resources.displayMetrics.density + ClusterRadialOverlay( + centerPx = ec.centerPx, + members = ec.members, + density = d, + onNodeClicked = { node -> + selectedNodeNum = node.num + expandedCluster = null + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + DEFAULT_ZOOM_LEVEL, + ), + ) + } + }, + modifier = Modifier.align(Alignment.TopStart), + ) + } + + // Layer management bottom sheet + if (showLayersBottomSheet) { + ModalBottomSheet( + onDismissRequest = { showLayersBottomSheet = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + CustomMapLayersSheet( + mapLayers = mapLayers, + onToggleVisibility = ::toggleLayerVisibility, + onRemoveLayer = ::removeLayer, + onAddLayerClicked = { + showLayersBottomSheet = false + openFilePicker() + }, + markerColorMode = markerColorMode, + onMarkerColorModeChange = { mode -> markerColorMode = mode }, + ) + } + } + + // Tile cache management bottom sheet + if (showCacheBottomSheet && tileCacheManager != null) { + ModalBottomSheet( + onDismissRequest = { showCacheBottomSheet = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + TileCacheManagementSheet( + cacheManager = tileCacheManager!!, + onDismiss = { showCacheBottomSheet = false }, + ) + } + } + + // Bottom sheet with node details and actions + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val selectedNode = selectedNodeNum?.let { num -> nodes.firstOrNull { it.num == num } } + + // Cluster list bottom sheet (for large clusters) + clusterListMembers?.let { members -> + ClusterListBottomSheet( + members = members, + onNodeClicked = { node -> + selectedNodeNum = node.num + clusterListMembers = null + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + DEFAULT_ZOOM_LEVEL, + ), + ) + } + }, + onDismiss = { clusterListMembers = null }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) + } + + // Node details bottom sheet + if (selectedNode != null) { + val lastHeardAgo = formatSecondsAgo(selectedNode.lastHeard) + val coords = selectedNode.gpsString() + val km = ourNode?.let { me -> distanceKmBetween(me, selectedNode) } + val distanceKm = km?.let { "%.1f".format(it) } + + NodeDetailsBottomSheet( + node = selectedNode, + lastHeardAgo = lastHeardAgo, + coords = coords, + distanceKm = distanceKm, + onViewFullNode = { + onNavigateToNodeDetails(selectedNode.num) + selectedNodeNum = null + }, + onDismiss = { selectedNodeNum = null }, + sheetState = sheetState, + ) + } + // Waypoint editing dialog + 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 }, + ) + } + } + + // Forward lifecycle events to MapView + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + // Note: AndroidView handles View lifecycle, but MapView benefits from explicit forwarding + when (event) { + Lifecycle.Event.ON_START -> {} + Lifecycle.Event.ON_RESUME -> {} + Lifecycle.Event.ON_PAUSE -> {} + Lifecycle.Event.ON_STOP -> {} + Lifecycle.Event.ON_DESTROY -> {} + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } +} + +private fun MapLibreMap.applyZoomPreferences(style: BaseMapStyle) { + setMinZoomPreference(style.minZoom.toDouble()) + setMaxZoomPreference(style.maxZoom.toDouble()) +} + +private fun applyMarkerColorMode(style: Style, mode: MarkerColorMode) { + val expression = + when (mode) { + MarkerColorMode.ROLE -> markerColorExpression(ROLE_COLOR_PROPERTY) + MarkerColorMode.NODE -> markerColorExpression(NODE_COLOR_PROPERTY) + } + style.getLayer(NODES_LAYER_ID)?.setProperties(circleColor(expression)) + style.getLayer(NODES_LAYER_NOCLUSTER_ID)?.setProperties(circleColor(expression)) +} + +private fun markerColorExpression(primaryProperty: String) = + coalesce(toColor(get(primaryProperty)), toColor(get(ROLE_COLOR_PROPERTY)), toColor(literal("#2E7D32"))) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt new file mode 100644 index 0000000000..983149d1e9 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt @@ -0,0 +1,432 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.maplibre.ui + +import android.graphics.PointF +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.offset +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.material.icons.Icons +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Layers +import androidx.compose.material.icons.outlined.Map +import androidx.compose.material.icons.outlined.MyLocation +import androidx.compose.material.icons.outlined.Palette +import androidx.compose.material.icons.outlined.Remove +import androidx.compose.material.icons.outlined.Storage +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalFloatingToolbar +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +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.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.maps.MapLibreMap +import org.meshtastic.core.database.model.Node +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.marker_color_mode_node +import org.meshtastic.core.strings.marker_color_mode_role +import org.meshtastic.core.ui.theme.StatusColors.StatusRed +import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState +import org.meshtastic.feature.map.MarkerColorMode +import org.meshtastic.feature.map.component.MapButton +import org.meshtastic.feature.map.maplibre.BaseMapStyle +import org.meshtastic.feature.map.maplibre.utils.protoShortName +import org.meshtastic.feature.map.maplibre.utils.roleColor +import org.meshtastic.feature.map.maplibre.utils.shortNameFallback +import org.meshtastic.proto.ConfigProtos +import timber.log.Timber + +/** Role legend overlay showing colors for different node roles */ +@Composable +fun RoleLegend(nodes: List, modifier: Modifier = Modifier) { + val rolesPresent = nodes.map { it.user.role }.toSet() + + if (rolesPresent.isNotEmpty()) { + Surface(modifier = modifier, tonalElevation = 4.dp, shadowElevation = 4.dp) { + Column(modifier = Modifier.padding(8.dp)) { + rolesPresent.take(6).forEach { role -> + val fakeNode = + Node(num = 0, user = org.meshtastic.proto.MeshProtos.User.newBuilder().setRole(role).build()) + Row(modifier = Modifier.padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + Surface(shape = CircleShape, color = roleColor(fakeNode), modifier = Modifier.size(12.dp)) {} + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = role.name.lowercase().replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + } + } +} + +/** Map toolbar with GPS, filter, map style, and layers controls */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun MapToolbar( + hasLocationPermission: Boolean, + isLocationTrackingEnabled: Boolean, + followBearing: Boolean, + onLocationTrackingChanged: (enabled: Boolean, follow: Boolean) -> Unit, + mapFilterState: MapFilterState, + onToggleOnlyFavorites: () -> Unit, + onToggleShowPrecisionCircle: () -> Unit, + nodes: List, + enabledRoles: Set, + onRoleToggled: (ConfigProtos.Config.DeviceConfig.Role) -> Unit, + clusteringEnabled: Boolean, + onClusteringToggled: (Boolean) -> Unit, + baseStyles: List, + baseStyleIndex: Int, + usingCustomTiles: Boolean, + onStyleSelected: (Int) -> Unit, + customTileUrl: String, + onCustomTileClicked: () -> Unit, + onShowLayersClicked: () -> Unit, + onShowCacheClicked: () -> Unit, + markerColorMode: MarkerColorMode, + onMarkerColorModeToggle: () -> Unit, + heatmapEnabled: Boolean, + onHeatmapToggled: () -> Unit, + modifier: Modifier = Modifier, +) { + var mapFilterExpanded by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } + var mapTypeMenuExpanded by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } + + HorizontalFloatingToolbar( + modifier = modifier, + expanded = true, + content = { + // Consolidated GPS button (cycles through: Off -> On -> On with bearing) + if (hasLocationPermission) { + val gpsIcon = + when { + isLocationTrackingEnabled && followBearing -> Icons.Filled.MyLocation + isLocationTrackingEnabled -> Icons.Filled.MyLocation + else -> Icons.Outlined.MyLocation + } + MapButton( + onClick = { + when { + !isLocationTrackingEnabled -> { + // Off -> On + onLocationTrackingChanged(true, false) + Timber.tag("MapLibrePOC").d("GPS tracking enabled") + } + isLocationTrackingEnabled && !followBearing -> { + // On -> On with bearing + onLocationTrackingChanged(true, true) + Timber.tag("MapLibrePOC").d("GPS tracking with bearing enabled") + } + else -> { + // On with bearing -> Off + onLocationTrackingChanged(false, false) + Timber.tag("MapLibrePOC").d("GPS tracking disabled") + } + } + }, + icon = gpsIcon, + contentDescription = null, + iconTint = + MaterialTheme.colorScheme.StatusRed.takeIf { isLocationTrackingEnabled && !followBearing }, + ) + } + + // Filter menu + Box { + MapButton(onClick = { mapFilterExpanded = true }, icon = Icons.Outlined.Tune, contentDescription = null) + DropdownMenu(expanded = mapFilterExpanded, onDismissRequest = { mapFilterExpanded = false }) { + DropdownMenuItem( + text = { Text("Only favorites") }, + onClick = { + onToggleOnlyFavorites() + mapFilterExpanded = false + }, + trailingIcon = { + Checkbox( + checked = mapFilterState.onlyFavorites, + onCheckedChange = { onToggleOnlyFavorites() }, + ) + }, + ) + DropdownMenuItem( + text = { Text("Show precision circle") }, + onClick = { + onToggleShowPrecisionCircle() + mapFilterExpanded = false + }, + trailingIcon = { + Checkbox( + checked = mapFilterState.showPrecisionCircle, + onCheckedChange = { onToggleShowPrecisionCircle() }, + ) + }, + ) + androidx.compose.material3.HorizontalDivider() + Text( + text = "Roles", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + val roles = nodes.map { it.user.role }.distinct().sortedBy { it.name } + roles.forEach { role -> + val checked = if (enabledRoles.isEmpty()) true else enabledRoles.contains(role) + DropdownMenuItem( + text = { Text(role.name.lowercase().replaceFirstChar { it.uppercase() }) }, + onClick = { onRoleToggled(role) }, + trailingIcon = { Checkbox(checked = checked, onCheckedChange = { onRoleToggled(role) }) }, + ) + } + androidx.compose.material3.HorizontalDivider() + DropdownMenuItem( + text = { Text("Enable clustering") }, + onClick = { onClusteringToggled(!clusteringEnabled) }, + trailingIcon = { + Checkbox(checked = clusteringEnabled, onCheckedChange = { onClusteringToggled(it) }) + }, + ) + DropdownMenuItem( + text = { Text("Show heatmap") }, + onClick = { + onHeatmapToggled() + mapFilterExpanded = false + }, + trailingIcon = { Checkbox(checked = heatmapEnabled, onCheckedChange = { onHeatmapToggled() }) }, + ) + } + } + + // Map style selector + Box { + MapButton( + onClick = { mapTypeMenuExpanded = true }, + icon = Icons.Outlined.Map, + contentDescription = null, + ) + DropdownMenu(expanded = mapTypeMenuExpanded, onDismissRequest = { mapTypeMenuExpanded = false }) { + Text( + text = "Map Style", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + baseStyles.forEachIndexed { index, style -> + DropdownMenuItem( + text = { Text(style.label) }, + onClick = { + onStyleSelected(index) + mapTypeMenuExpanded = false + }, + trailingIcon = { + if (index == baseStyleIndex && !usingCustomTiles) { + Icon(imageVector = Icons.Outlined.Check, contentDescription = "Selected") + } + }, + ) + } + androidx.compose.material3.HorizontalDivider() + DropdownMenuItem( + text = { + Text( + if (customTileUrl.isEmpty()) { + "Custom Tile URL..." + } else { + "Custom: ${customTileUrl.take(30)}..." + }, + ) + }, + onClick = { + mapTypeMenuExpanded = false + onCustomTileClicked() + }, + trailingIcon = { + if (usingCustomTiles && customTileUrl.isNotEmpty()) { + Icon(imageVector = Icons.Outlined.Check, contentDescription = "Selected") + } + }, + ) + } + } + + // Map layers button + MapButton(onClick = onShowLayersClicked, icon = Icons.Outlined.Layers, contentDescription = null) + + // Cache management button + MapButton(onClick = onShowCacheClicked, icon = Icons.Outlined.Storage, contentDescription = null) + + MapButton( + onClick = onMarkerColorModeToggle, + icon = Icons.Outlined.Palette, + contentDescription = + stringResource( + if (markerColorMode == MarkerColorMode.ROLE) { + Res.string.marker_color_mode_role + } else { + Res.string.marker_color_mode_node + }, + ), + ) + }, + ) +} + +/** Zoom controls (zoom in/out buttons) */ +@Composable +fun ZoomControls(mapRef: MapLibreMap?, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + shadowElevation = 3.dp, + ) { + Column(modifier = Modifier.padding(4.dp)) { + // Zoom in button + MapButton( + onClick = { + mapRef?.let { map -> + map.animateCamera(CameraUpdateFactory.zoomIn()) + Timber.tag("MapLibrePOC").d("Zoom in") + } + }, + icon = Icons.Outlined.Add, + contentDescription = "Zoom in", + ) + + Spacer(modifier = Modifier.size(4.dp)) + + // Zoom out button + MapButton( + onClick = { + mapRef?.let { map -> + map.animateCamera(CameraUpdateFactory.zoomOut()) + Timber.tag("MapLibrePOC").d("Zoom out") + } + }, + icon = Icons.Outlined.Remove, + contentDescription = "Zoom out", + ) + } + } +} + +/** Custom tile URL configuration dialog */ +@Composable +fun CustomTileDialog( + customTileUrlInput: String, + onCustomTileUrlInputChanged: (String) -> Unit, + onApply: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Custom Tile URL") }, + text = { + Column { + Text( + text = "Enter tile URL with {z}/{x}/{y} placeholders:", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + text = "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp), + ) + OutlinedTextField( + value = customTileUrlInput, + onValueChange = onCustomTileUrlInputChanged, + label = { Text("Tile URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { TextButton(onClick = onApply) { Text("Apply") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +/** Radial overlay showing cluster members in a circle */ +@Composable +fun ClusterRadialOverlay( + centerPx: PointF, + members: List, + density: Float, + onNodeClicked: (Node) -> Unit, + modifier: Modifier = Modifier, +) { + val centerX = (centerPx.x / density).dp + val centerY = (centerPx.y / density).dp + val radiusPx = 72f * density + val itemSize = 40.dp + val n = members.size.coerceAtLeast(1) + + members.forEachIndexed { idx, node -> + val theta = (2.0 * Math.PI * idx / n) + val x = (centerPx.x + (radiusPx * kotlin.math.cos(theta))).toFloat() + val y = (centerPx.y + (radiusPx * kotlin.math.sin(theta))).toFloat() + val xDp = (x / density).dp + val yDp = (y / density).dp + val label = (protoShortName(node) ?: shortNameFallback(node)).take(4) + val itemHeight = 36.dp + val itemWidth = (40 + label.length * 10).dp + + Surface( + modifier = + modifier + .offset(x = xDp - itemWidth / 2, y = yDp - itemHeight / 2) + .size(width = itemWidth, height = itemHeight) + .clickable { onNodeClicked(node) }, + shape = CircleShape, + color = roleColor(node), + shadowElevation = 6.dp, + ) { + Box(contentAlignment = Alignment.Center) { Text(text = label, color = Color.White, maxLines = 1) } + } + } +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreHelpers.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreHelpers.kt new file mode 100644 index 0000000000..3bf5a1654b --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreHelpers.kt @@ -0,0 +1,216 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.maplibre.utils + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.compose.ui.graphics.Color +import androidx.core.content.ContextCompat +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.maps.MapLibreMap +import org.meshtastic.core.database.model.Node +import org.meshtastic.feature.map.BaseMapViewModel +import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D +import org.meshtastic.proto.ConfigProtos + +/** Check if the app has any location permission */ +fun hasAnyLocationPermission(context: Context): Boolean { + val fine = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + val coarse = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + return fine || coarse +} + +/** Get protocol-defined short name if present */ +fun protoShortName(node: Node): String? { + val s = node.user.shortName + return if (s.isNullOrBlank()) null else s +} + +/** Fallback short name generation */ +fun shortNameFallback(node: Node): String { + val long = node.user.longName + if (!long.isNullOrBlank()) return safeSubstring(long, 4) + val hex = node.num.toString(16).uppercase() + return if (hex.length >= 4) hex.takeLast(4) else hex +} + +/** Safely take up to maxLength characters, respecting emoji boundaries */ +fun safeSubstring(text: String, maxLength: Int): String { + if (text.length <= maxLength) return text + + // Use grapheme cluster breaking to respect emoji boundaries + var count = 0 + var lastSafeIndex = 0 + + val breakIterator = java.text.BreakIterator.getCharacterInstance() + breakIterator.setText(text) + + var start = breakIterator.first() + var end = breakIterator.next() + + while (end != java.text.BreakIterator.DONE && count < maxLength) { + lastSafeIndex = end + count++ + end = breakIterator.next() + } + + return if (lastSafeIndex > 0) text.substring(0, lastSafeIndex) else text.take(maxLength) +} + +/** + * Remove emojis from text for MapLibre rendering Returns null if the text is emoji-only (so caller can use fallback + * like hex ID) + */ +fun stripEmojisForMapLabel(text: String): String? { + if (text.isEmpty()) return null + + // Filter to keep only characters that MapLibre can reliably render + val filtered = + text + .filter { ch -> + ch.code in 0x20..0x7E || // Basic ASCII printable characters + ch.code in 0xA0..0xFF // Latin-1 supplement (accented characters) + } + .trim() + + // If filtering removed everything, return null (caller should use fallback) + return if (filtered.isEmpty()) null else filtered +} + +/** Select one label per grid cell in the current viewport, prioritizing favorites and recent nodes */ +fun selectLabelsForViewport(map: MapLibreMap, nodes: List, density: Float): Set { + val bounds = map.projection.visibleRegion.latLngBounds + val visible = + nodes.filter { n -> + val p = n.validPosition ?: return@filter false + val lat = p.latitudeI * DEG_D + val lon = p.longitudeI * DEG_D + bounds.contains(LatLng(lat, lon)) + } + if (visible.isEmpty()) return emptySet() + + // Priority: favorites first, then more recently heard + val sorted = visible.sortedWith(compareByDescending { it.isFavorite }.thenByDescending { it.lastHeard }) + + // Dynamic cell size by zoom so more labels appear as you zoom in + val zoom = map.cameraPosition.zoom + val baseCellDp = + when { + zoom < 10 -> 96f + zoom < 11 -> 88f + zoom < 12 -> 80f + zoom < 13 -> 72f + zoom < 14 -> 64f + zoom < 15 -> 56f + zoom < 16 -> 48f + else -> 36f + } + val cellSizePx = (baseCellDp * density).toInt().coerceAtLeast(32) + val occupied = HashSet() + val chosen = LinkedHashSet() + for (n in sorted) { + val p = n.validPosition ?: continue + val pt = map.projection.toScreenLocation(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + val cx = (pt.x / cellSizePx).toInt() + val cy = (pt.y / cellSizePx).toInt() + val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) + if (occupied.add(key)) { + chosen.add(n.num) + } + } + return chosen +} + +/** Human friendly "x min ago" from epoch seconds */ +fun formatSecondsAgo(lastHeardEpochSeconds: Int): String { + val now = System.currentTimeMillis() / 1000 + val delta = (now - lastHeardEpochSeconds).coerceAtLeast(0) + val minutes = delta / 60 + val hours = minutes / 60 + val days = hours / 24 + return when { + delta < 60 -> "$delta s ago" + minutes < 60 -> "$minutes min ago" + hours < 24 -> "$hours h ago" + else -> "$days d ago" + } +} + +/** Simple haversine distance between two nodes in kilometers */ +fun distanceKmBetween(a: Node, b: Node): Double? { + val pa = a.validPosition ?: return null + val pb = b.validPosition ?: return null + val lat1 = pa.latitudeI * DEG_D + val lon1 = pa.longitudeI * DEG_D + val lat2 = pb.latitudeI * DEG_D + val lon2 = pb.longitudeI * DEG_D + val radius = 6371.0 // km (Earth's radius) + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val s1 = kotlin.math.sin(dLat / 2) + val s2 = kotlin.math.sin(dLon / 2) + val aTerm = s1 * s1 + kotlin.math.cos(Math.toRadians(lat1)) * kotlin.math.cos(Math.toRadians(lat2)) * s2 * s2 + val c = 2 * kotlin.math.atan2(kotlin.math.sqrt(aTerm), kotlin.math.sqrt(1 - aTerm)) + return radius * c +} + +/** Get hex color for a node based on its role */ +fun roleColorHex(node: Node): String = when (node.user.role) { + ConfigProtos.Config.DeviceConfig.Role.ROUTER -> "#D32F2F" // red (infrastructure) + ConfigProtos.Config.DeviceConfig.Role.ROUTER_CLIENT -> "#00897B" // teal + ConfigProtos.Config.DeviceConfig.Role.REPEATER -> "#7B1FA2" // purple + ConfigProtos.Config.DeviceConfig.Role.TRACKER -> "#8E24AA" // purple (lighter) + ConfigProtos.Config.DeviceConfig.Role.SENSOR -> "#1E88E5" // blue + ConfigProtos.Config.DeviceConfig.Role.TAK, + ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER, + -> "#F57C00" // orange (TAK) + ConfigProtos.Config.DeviceConfig.Role.CLIENT -> "#2E7D32" // green + ConfigProtos.Config.DeviceConfig.Role.CLIENT_BASE -> "#1976D2" // blue (client base) + ConfigProtos.Config.DeviceConfig.Role.CLIENT_MUTE -> "#9E9D24" // olive + ConfigProtos.Config.DeviceConfig.Role.CLIENT_HIDDEN -> "#546E7A" // blue-grey + ConfigProtos.Config.DeviceConfig.Role.LOST_AND_FOUND -> "#AD1457" // magenta + ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE -> "#E57373" // light red (late router) + null, + ConfigProtos.Config.DeviceConfig.Role.UNRECOGNIZED, + -> "#2E7D32" // default green +} + +/** Get Color object for a node based on its role */ +fun roleColor(node: Node): Color = Color(android.graphics.Color.parseColor(roleColorHex(node))) + +/** Get hex color for a node based on its unique node color */ +fun nodeColorHex(node: Node): String = String.format("#%06X", 0xFFFFFF and node.colors.second) + +/** Apply filters to node list */ +fun applyFilters( + all: List, + filter: BaseMapViewModel.MapFilterState, + enabledRoles: Set, + ourNodeNum: Int? = null, + isLocationTrackingEnabled: Boolean = false, +): List { + var out = all + if (filter.onlyFavorites) out = out.filter { it.isFavorite } + if (enabledRoles.isNotEmpty()) out = out.filter { enabledRoles.contains(it.user.role) } + return out +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt new file mode 100644 index 0000000000..b355d15698 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt @@ -0,0 +1,354 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.maplibre.utils + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.core.net.toFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection +import org.maplibre.geojson.LineString +import org.maplibre.geojson.Point +import org.maplibre.geojson.Polygon +import org.meshtastic.feature.map.LayerType +import org.meshtastic.feature.map.MapLayerItem +import org.w3c.dom.Element +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.util.zip.ZipInputStream +import javax.xml.parsers.DocumentBuilderFactory + +/** Loads persisted map layers from internal storage */ +suspend fun loadPersistedLayers(context: Context): List = withContext(Dispatchers.IO) { + try { + val layersDir = File(context.filesDir, "map_layers") + if (layersDir.exists() && layersDir.isDirectory) { + val persistedLayerFiles = layersDir.listFiles() + 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 = true, layerType = it) + } + } else { + null + } + } ?: emptyList() + } else { + emptyList() + } + } catch (e: Exception) { + Timber.tag("MapLibreLayerUtils").e(e, "Error loading persisted map layers") + emptyList() + } +} + +/** Copies a file from URI to internal storage */ +suspend fun copyFileToInternalStorage(context: Context, uri: Uri, fileName: String): Uri? = + withContext(Dispatchers.IO) { + try { + val inputStream = context.contentResolver.openInputStream(uri) + val directory = File(context.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) { + Timber.tag("MapLibreLayerUtils").e(e, "Error copying file to internal storage") + null + } + } + +/** Deletes a file from internal storage */ +suspend fun deleteFileFromInternalStorage(uri: Uri) = withContext(Dispatchers.IO) { + try { + val file = uri.toFile() + if (file.exists()) { + file.delete() + } + } catch (e: Exception) { + Timber.tag("MapLibreLayerUtils").e(e, "Error deleting file from internal storage") + } +} + +/** + * Gets InputStream from URI. + * + * **Important:** Caller is responsible for closing the returned stream using `.use {}` or try-with-resources. + * + * @Suppress("Recycle") is used because this function intentionally returns an unclosed stream for the caller to manage. + */ +@Suppress("Recycle") +suspend fun getInputStreamFromUri(context: Context, uri: Uri): InputStream? = withContext(Dispatchers.IO) { + try { + context.contentResolver.openInputStream(uri) + } catch (_: Exception) { + Timber.d("MapLibreLayerUtils: Error opening InputStream from URI: $uri") + null + } +} + +/** Converts KML content to GeoJSON string */ +suspend fun convertKmlToGeoJson(context: Context, layerItem: MapLayerItem): String? = withContext(Dispatchers.IO) { + try { + val uri = layerItem.uri ?: return@withContext null + Timber.tag("MapLibreLayerUtils").d("convertKmlToGeoJson: uri=%s", uri) + val inputStream = getInputStreamFromUri(context, uri) ?: return@withContext null + + inputStream.use { stream -> + // Handle KMZ (ZIP) files + val isKmz = layerItem.layerType == LayerType.KML && uri.toString().endsWith(".kmz", ignoreCase = true) + Timber.tag("MapLibreLayerUtils").d("File type: isKmz=%s", isKmz) + + val content = + if (isKmz) { + Timber.tag("MapLibreLayerUtils").d("Extracting KML from KMZ...") + extractKmlFromKmz(stream) + } else { + Timber.tag("MapLibreLayerUtils").d("Reading KML directly...") + stream.bufferedReader().use { it.readText() } + } + + if (content == null) { + Timber.tag("MapLibreLayerUtils").w("Failed to extract KML content") + return@withContext null + } + + Timber.tag("MapLibreLayerUtils").d("KML content extracted: %d bytes", content.length) + val result = parseKmlToGeoJson(content) + Timber.tag("MapLibreLayerUtils").d("KML parsed to GeoJSON: %d bytes", result.length) + result + } + } catch (e: Exception) { + Timber.tag("MapLibreLayerUtils").e(e, "Error converting KML to GeoJSON") + null + } +} + +/** Extracts KML content from KMZ (ZIP) file */ +private fun extractKmlFromKmz(inputStream: InputStream): String? { + return try { + val zipInputStream = ZipInputStream(inputStream) + var entry = zipInputStream.nextEntry + + // Look for KML file in the ZIP (usually named "doc.kml" or similar) + while (entry != null) { + val fileName = entry.name.lowercase() + if (fileName.endsWith(".kml")) { + // Read content without closing the stream + val kmlContent = zipInputStream.bufferedReader().readText() + zipInputStream.closeEntry() + Timber.tag("MapLibreLayerUtils").d("Extracted KML from KMZ: ${entry.name}") + return kmlContent + } + zipInputStream.closeEntry() + entry = zipInputStream.nextEntry + } + + Timber.tag("MapLibreLayerUtils").w("No KML file found in KMZ archive") + null + } catch (e: Exception) { + Timber.tag("MapLibreLayerUtils").e(e, "Error extracting KML from KMZ") + null + } +} + +/** Parses KML XML and converts to GeoJSON */ +private fun parseKmlToGeoJson(kmlContent: String): String { + try { + Timber.tag("MapLibreLayerUtils").d("parseKmlToGeoJson: Parsing %d bytes of KML", kmlContent.length) + + // Log first 500 chars of KML to see structure + val preview = kmlContent.take(500) + Timber.tag("MapLibreLayerUtils").d("KML preview: %s", preview) + + val factory = DocumentBuilderFactory.newInstance() + factory.isNamespaceAware = true + val builder = factory.newDocumentBuilder() + val doc = builder.parse(kmlContent.byteInputStream()) + + // Log root element + val root = doc.documentElement + Timber.tag("MapLibreLayerUtils").d("Root element: tagName=%s, namespaceURI=%s", root.tagName, root.namespaceURI) + + val features = mutableListOf() + + // Parse Placemarks (points, lines, polygons) + val placemarks = doc.getElementsByTagName("Placemark") + Timber.tag("MapLibreLayerUtils").d("Found %d Placemark elements", placemarks.length) + + // Also try with namespace + val placemarks2 = doc.getElementsByTagNameNS("*", "Placemark") + Timber.tag("MapLibreLayerUtils").d("Found %d Placemark elements (with NS wildcard)", placemarks2.length) + + // Check for GroundOverlays (raster images) + val groundOverlays = doc.getElementsByTagName("GroundOverlay") + if (groundOverlays.length > 0) { + Timber.tag("MapLibreLayerUtils") + .w( + "Found %d GroundOverlay elements (raster tiles). These are not supported - only vector features (Placemarks) can be displayed.", + groundOverlays.length, + ) + } + + for (i in 0 until placemarks.length) { + val placemark = placemarks.item(i) as? Element ?: continue + val name = placemark.getElementsByTagName("name").item(0)?.textContent ?: "" + + // Try Point + val point = placemark.getElementsByTagName("Point").item(0) as? Element + point?.let { + val coordinates = it.getElementsByTagName("coordinates").item(0)?.textContent?.trim() + coordinates?.let { coordStr -> + val parts = coordStr.split(",") + if (parts.size >= 2) { + val lon = parts[0].toDoubleOrNull() ?: return@let + val lat = parts[1].toDoubleOrNull() ?: return@let + val point = Point.fromLngLat(lon, lat) + val feature = Feature.fromGeometry(point) + feature.addStringProperty("name", name) + features.add(feature) + } + } + } + + // Try LineString + val lineString = placemark.getElementsByTagName("LineString").item(0) as? Element + lineString?.let { + val coordinates = it.getElementsByTagName("coordinates").item(0)?.textContent?.trim() + coordinates?.let { coordStr -> + val points = parseCoordinates(coordStr) + if (points.size >= 2) { + val lineString = LineString.fromLngLats(points) + val feature = Feature.fromGeometry(lineString) + feature.addStringProperty("name", name) + features.add(feature) + } + } + } + + // Try Polygon + val polygon = placemark.getElementsByTagName("Polygon").item(0) as? Element + polygon?.let { + val outerBoundary = it.getElementsByTagName("outerBoundaryIs").item(0) as? Element + outerBoundary?.let { + val linearRing = it.getElementsByTagName("LinearRing").item(0) as? Element + linearRing?.let { + val coordinates = it.getElementsByTagName("coordinates").item(0)?.textContent?.trim() + coordinates?.let { coordStr -> + val points = parseCoordinates(coordStr) + if (points.size >= 3) { + // Close the polygon + val closedPoints = points + points[0] + val polygon = Polygon.fromLngLats(listOf(closedPoints)) + val feature = Feature.fromGeometry(polygon) + feature.addStringProperty("name", name) + features.add(feature) + } + } + } + } + } + } + + Timber.tag("MapLibreLayerUtils").d("Parsed %d features from KML", features.size) + val featureCollection = FeatureCollection.fromFeatures(features) + val json = featureCollection.toJson() + Timber.tag("MapLibreLayerUtils").d("Generated GeoJSON: %d bytes", json.length) + return json + } catch (e: Exception) { + Timber.tag("MapLibreLayerUtils").e(e, "Error parsing KML") + return """{"type":"FeatureCollection","features":[]}""" + } +} + +/** Parses coordinate string into list of Points */ +private fun parseCoordinates(coordStr: String): List = coordStr.split(" ").mapNotNull { line -> + val parts = line.trim().split(",") + if (parts.size >= 2) { + val lon = parts[0].toDoubleOrNull() + val lat = parts[1].toDoubleOrNull() + if (lon != null && lat != null) { + Point.fromLngLat(lon, lat) + } else { + null + } + } else { + null + } +} + +/** Loads GeoJSON from a layer item (converting KML if needed) */ +suspend fun loadLayerGeoJson(context: Context, layerItem: MapLayerItem): String? = withContext(Dispatchers.IO) { + Timber.tag("MapLibreLayerUtils") + .d("loadLayerGeoJson: name=%s, type=%s, uri=%s", layerItem.name, layerItem.layerType, layerItem.uri) + val result = + when (layerItem.layerType) { + LayerType.KML -> { + Timber.tag("MapLibreLayerUtils").d("Converting KML to GeoJSON for: %s", layerItem.name) + convertKmlToGeoJson(context, layerItem) + } + LayerType.GEOJSON -> { + Timber.tag("MapLibreLayerUtils").d("Loading GeoJSON directly for: %s", layerItem.name) + val uri = layerItem.uri ?: return@withContext null + getInputStreamFromUri(context, uri)?.use { stream -> stream.bufferedReader().use { it.readText() } } + } + } + if (result != null) { + Timber.tag("MapLibreLayerUtils") + .d("Successfully loaded GeoJSON for %s: %d bytes", layerItem.name, result.length) + } else { + Timber.tag("MapLibreLayerUtils").e("Failed to load GeoJSON for: %s", layerItem.name) + } + result +} + +/** Extension function to get file name from URI */ +fun Uri.getFileName(context: Context): String? { + var name = this.lastPathSegment + if (this.scheme == "content") { + context.contentResolver.query(this, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1) { + name = cursor.getString(nameIndex) + } + } + } + } + return name +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreTileCacheManager.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreTileCacheManager.kt new file mode 100644 index 0000000000..234fc0580d --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreTileCacheManager.kt @@ -0,0 +1,108 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.maplibre.utils + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.maplibre.android.offline.OfflineManager +import org.maplibre.android.offline.OfflineRegion +import timber.log.Timber + +/** + * Simplified tile cache manager for MapLibre. Provides basic cache clearing functionality. Note: MapLibre automatically + * caches tiles via HTTP caching as you view the map. + */ +class MapLibreTileCacheManager(private val context: Context) { + // Lazy initialization - only create OfflineManager after MapLibre is initialized + private val offlineManager: OfflineManager by lazy { OfflineManager.getInstance(context) } + + /** + * Clears the ambient cache (automatic HTTP tile cache). MapLibre automatically caches tiles in the "ambient cache" + * as you view the map. This method clears that cache to free up storage space. + */ + suspend fun clearCache() = withContext(Dispatchers.IO) { + Timber.tag("MapLibreTileCacheManager").d("Clearing ambient cache...") + + offlineManager.clearAmbientCache( + object : OfflineManager.FileSourceCallback { + override fun onSuccess() { + Timber.tag("MapLibreTileCacheManager").d("Successfully cleared ambient cache") + } + + override fun onError(message: String) { + Timber.tag("MapLibreTileCacheManager").e("Failed to clear ambient cache: $message") + } + }, + ) + + // Also delete any offline regions if they exist (from the old broken implementation) + offlineManager.listOfflineRegions( + object : OfflineManager.ListOfflineRegionsCallback { + override fun onList(offlineRegions: Array?) { + if (offlineRegions == null || offlineRegions.isEmpty()) { + Timber.tag("MapLibreTileCacheManager").d("No offline regions to clean up") + return + } + + Timber.tag("MapLibreTileCacheManager") + .d("Cleaning up ${offlineRegions.size} offline regions from old implementation") + offlineRegions.forEach { region -> + region.delete( + object : OfflineRegion.OfflineRegionDeleteCallback { + override fun onDelete() { + Timber.tag("MapLibreTileCacheManager").d("Deleted offline region ${region.id}") + } + + override fun onError(error: String) { + Timber.tag("MapLibreTileCacheManager") + .e("Failed to delete region ${region.id}: $error") + } + }, + ) + } + } + + override fun onError(error: String) { + Timber.tag("MapLibreTileCacheManager").e("Failed to list offline regions: $error") + } + }, + ) + } + + /** + * Sets the maximum size for the ambient cache in bytes. Default is typically 50MB. Call this to increase or + * decrease the cache size. + */ + fun setMaximumAmbientCacheSize(sizeBytes: Long, callback: OfflineManager.FileSourceCallback? = null) { + Timber.tag("MapLibreTileCacheManager").d("Setting maximum ambient cache size to $sizeBytes bytes") + offlineManager.setMaximumAmbientCacheSize( + sizeBytes, + callback + ?: object : OfflineManager.FileSourceCallback { + override fun onSuccess() { + Timber.tag("MapLibreTileCacheManager").d("Successfully set maximum ambient cache size") + } + + override fun onError(message: String) { + Timber.tag("MapLibreTileCacheManager").e("Failed to set maximum ambient cache size: $message") + } + }, + ) + } +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreWaypointUtils.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreWaypointUtils.kt new file mode 100644 index 0000000000..97c54c037b --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreWaypointUtils.kt @@ -0,0 +1,52 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.maplibre.utils + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import androidx.core.graphics.createBitmap +import timber.log.Timber + +/** Convert a Unicode code point (int) to an emoji string */ +internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try { + String(Character.toChars(unicodeCodePoint)) +} catch (e: IllegalArgumentException) { + Timber.tag("MapLibrePOC").w(e, "Invalid unicode code point: $unicodeCodePoint") + "\uD83D\uDCCD" // 📍 default pin emoji +} + +/** Convert emoji to Bitmap for use as a MapLibre marker icon */ +internal fun unicodeEmojiToBitmap(icon: Int): Bitmap { + 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, Bitmap.Config.ARGB_8888) + val canvas = Canvas(image) + canvas.drawText(unicodeEmoji, width / 2f, baseline, paint) + + return image +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt index c79e813e78..cb9e994f32 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt @@ -17,47 +17,54 @@ package org.meshtastic.feature.map.node -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.meshtastic.feature.map.addCopyright -import org.meshtastic.feature.map.addPolyline -import org.meshtastic.feature.map.addPositionMarkers -import org.meshtastic.feature.map.addScaleBarOverlay -import org.meshtastic.feature.map.rememberMapViewWithLifecycle -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint - -private const val DEG_D = 1e-7 +import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.feature.map.MapViewModel +import org.meshtastic.feature.map.maplibre.ui.MapLibrePOC +import timber.log.Timber @Composable -fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { - val density = LocalDensity.current - val positionLogs by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() - val geoPoints = positionLogs.map { GeoPoint(it.latitudeI * DEG_D, it.longitudeI * DEG_D) } - val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) } - val mapView = - rememberMapViewWithLifecycle( - applicationId = nodeMapViewModel.applicationId, - box = cameraView, - tileSource = nodeMapViewModel.tileSource, - ) +fun NodeMapScreen( + nodeMapViewModel: NodeMapViewModel, + onNavigateUp: () -> Unit, + mapViewModel: MapViewModel = hiltViewModel(), +) { + val node by nodeMapViewModel.node.collectAsStateWithLifecycle() + val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() + val destNum = node?.num - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { mapView }, - update = { map -> - map.overlays.clear() - map.addCopyright() - map.addScaleBarOverlay(density) + Timber.tag("NodeMapScreen") + .d("NodeMapScreen rendering - destNum=%s, positions count=%d", destNum ?: "null", positions.size) - map.addPolyline(density, geoPoints) {} - map.addPositionMarkers(positionLogs) {} + Scaffold( + topBar = { + MainAppBar( + title = node?.user?.longName ?: "", + ourNode = null, + showNodeChip = false, + canNavigateUp = true, + onNavigateUp = onNavigateUp, + actions = {}, + onClickChip = {}, + ) }, - ) + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + Timber.tag("NodeMapScreen") + .d("Calling MapLibrePOC with focusedNodeNum=%s, nodeTracks count=%d", destNum ?: "null", positions.size) + MapLibrePOC( + mapViewModel = mapViewModel, + onNavigateToNodeDetails = {}, + focusedNodeNum = destNum, + nodeTracks = positions, + ) + } + } } diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index dbf9b2df78..c93e719027 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -464,7 +464,7 @@ fun MapView( mapViewModel.loadMapLayerIfNeeded(map, layerItem) when (layerItem.layerType) { LayerType.KML -> { - layerItem.kmlLayerData?.let { kmlLayer -> + mapViewModel.getKmlLayer(layerItem.id)?.let { kmlLayer -> if (layerItem.isVisible && !kmlLayer.isLayerOnMap) { kmlLayer.addLayerToMap() } else if (!layerItem.isVisible && kmlLayer.isLayerOnMap) { @@ -474,7 +474,7 @@ fun MapView( } LayerType.GEOJSON -> { - layerItem.geoJsonLayerData?.let { geoJsonLayer -> + mapViewModel.getGeoJsonLayer(layerItem.id)?.let { geoJsonLayer -> if (layerItem.isVisible && !geoJsonLayer.isLayerOnMap) { geoJsonLayer.addLayerToMap() } else if (!layerItem.isVisible && geoJsonLayer.isLayerOnMap) { diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt index f08a2c0d97..b064848188 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -40,7 +40,6 @@ 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 @@ -251,6 +250,9 @@ constructor( private val _mapLayers = MutableStateFlow>(emptyList()) val mapLayers: StateFlow> = _mapLayers.asStateFlow() + // Store Google Maps specific layer data separately + private val layerDataMap = mutableMapOf>() + init { viewModelScope.launch { customTileProviderRepository.getCustomTileProviders().first() @@ -432,11 +434,13 @@ constructor( fun removeMapLayer(layerId: String) { viewModelScope.launch { val layerToRemove = _mapLayers.value.find { it.id == layerId } + val layerData = layerDataMap[layerId] when (layerToRemove?.layerType) { - LayerType.KML -> layerToRemove.kmlLayerData?.removeLayerFromMap() - LayerType.GEOJSON -> layerToRemove.geoJsonLayerData?.removeLayerFromMap() + LayerType.KML -> layerData?.first?.removeLayerFromMap() + LayerType.GEOJSON -> layerData?.second?.removeLayerFromMap() null -> {} } + layerDataMap.remove(layerId) layerToRemove?.uri?.let { uri -> deleteFileToInternalStorage(uri) googleMapsPrefs.hiddenLayerUrls -= uri.toString() @@ -471,8 +475,12 @@ constructor( } } + fun getKmlLayer(layerId: String): KmlLayer? = layerDataMap[layerId]?.first + + fun getGeoJsonLayer(layerId: String): GeoJsonLayer? = layerDataMap[layerId]?.second + suspend fun loadMapLayerIfNeeded(map: GoogleMap, layerItem: MapLayerItem) { - if (layerItem.kmlLayerData != null || layerItem.geoJsonLayerData != null) return + if (layerDataMap[layerItem.id] != null) return try { when (layerItem.layerType) { LayerType.KML -> loadKmlLayerIfNeeded(layerItem, map) @@ -491,15 +499,8 @@ constructor( if (!layerItem.isVisible) removeLayerFromMap() } } - _mapLayers.update { currentLayers -> - currentLayers.map { - if (it.id == layerItem.id) { - it.copy(kmlLayerData = kmlLayer) - } else { - it - } - } - } + val current = layerDataMap[layerItem.id] ?: Pair(null, null) + layerDataMap[layerItem.id] = Pair(kmlLayer, current.second) } private suspend fun loadGeoJsonLayerIfNeeded(layerItem: MapLayerItem, map: GoogleMap) { @@ -508,35 +509,11 @@ constructor( val jsonObject = JSONObject(inputStream.bufferedReader().use { it.readText() }) GeoJsonLayer(map, jsonObject).apply { if (!layerItem.isVisible) removeLayerFromMap() } } - _mapLayers.update { currentLayers -> - currentLayers.map { - if (it.id == layerItem.id) { - it.copy(geoJsonLayerData = geoJsonLayer) - } else { - it - } - } - } + val current = layerDataMap[layerItem.id] ?: Pair(null, null) + layerDataMap[layerItem.id] = Pair(current.first, geoJsonLayer) } fun clearLoadedLayerData() { - _mapLayers.update { currentLayers -> - currentLayers.map { it.copy(kmlLayerData = null, geoJsonLayerData = null) } - } + layerDataMap.clear() } } - -enum class LayerType { - KML, - GEOJSON, -} - -data class MapLayerItem( - val id: String = UUID.randomUUID().toString(), - val name: String, - val uri: Uri? = null, - var isVisible: Boolean = true, - var kmlLayerData: KmlLayer? = null, - var geoJsonLayerData: GeoJsonLayer? = null, - val layerType: LayerType, -) diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt index 637a1e6536..a9ea7cc489 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt @@ -44,10 +44,15 @@ import org.meshtastic.core.strings.add_layer import org.meshtastic.core.strings.hide_layer import org.meshtastic.core.strings.manage_map_layers import org.meshtastic.core.strings.map_layer_formats +import org.meshtastic.core.strings.marker_color_mode_description +import org.meshtastic.core.strings.marker_color_mode_node +import org.meshtastic.core.strings.marker_color_mode_role +import org.meshtastic.core.strings.marker_color_mode_title import org.meshtastic.core.strings.no_map_layers_loaded import org.meshtastic.core.strings.remove_layer import org.meshtastic.core.strings.show_layer import org.meshtastic.feature.map.MapLayerItem +import org.meshtastic.feature.map.MarkerColorMode @Suppress("LongMethod") @Composable @@ -57,6 +62,8 @@ fun CustomMapLayersSheet( onToggleVisibility: (String) -> Unit, onRemoveLayer: (String) -> Unit, onAddLayerClicked: () -> Unit, + markerColorMode: MarkerColorMode? = null, + onMarkerColorModeChange: ((MarkerColorMode) -> Unit)? = null, ) { LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { item { @@ -74,6 +81,29 @@ fun CustomMapLayersSheet( style = MaterialTheme.typography.bodySmall, ) } + if (markerColorMode != null && onMarkerColorModeChange != null) { + item { + ListItem( + headlineContent = { Text(stringResource(Res.string.marker_color_mode_title)) }, + supportingContent = { Text(stringResource(Res.string.marker_color_mode_description)) }, + trailingContent = { + Button(onClick = { onMarkerColorModeChange(markerColorMode.toggle()) }) { + Text( + text = + stringResource( + if (markerColorMode == MarkerColorMode.ROLE) { + Res.string.marker_color_mode_role + } else { + Res.string.marker_color_mode_node + }, + ), + ) + } + }, + ) + HorizontalDivider() + } + } if (mapLayers.isEmpty()) { item { diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapLayerItem.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapLayerItem.kt new file mode 100644 index 0000000000..af78266059 --- /dev/null +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapLayerItem.kt @@ -0,0 +1,34 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map + +import android.net.Uri +import java.util.UUID + +enum class LayerType { + KML, + GEOJSON, +} + +data class MapLayerItem( + val id: String = UUID.randomUUID().toString(), + val name: String, + val uri: Uri? = null, + val isVisible: Boolean = true, + val layerType: LayerType, +) diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/MarkerColorMode.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/MarkerColorMode.kt new file mode 100644 index 0000000000..bd66a2e92d --- /dev/null +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/MarkerColorMode.kt @@ -0,0 +1,30 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map + +/** Determines which color family node markers should use. */ +enum class MarkerColorMode { + ROLE, + NODE, + ; + + fun toggle(): MarkerColorMode = when (this) { + ROLE -> NODE + NODE -> ROLE + } +}