From 1076f97132b28ff73549f813e07c227d5e52ab59 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:07:45 -0600 Subject: [PATCH 1/4] refactor: update string resource formatting in Context extensions Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../org/meshtastic/core/resources/ContextExt.kt | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/core/resources/src/androidMain/kotlin/org/meshtastic/core/resources/ContextExt.kt b/core/resources/src/androidMain/kotlin/org/meshtastic/core/resources/ContextExt.kt index ad3f4c9a2c..54aa4760ae 100644 --- a/core/resources/src/androidMain/kotlin/org/meshtastic/core/resources/ContextExt.kt +++ b/core/resources/src/androidMain/kotlin/org/meshtastic/core/resources/ContextExt.kt @@ -25,7 +25,13 @@ fun getString(stringResource: StringResource): String = runBlocking { composeGet /** Retrieves a formatted string from the [StringResource] in a blocking manner. */ fun getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking { - composeGetString(stringResource, *formatArgs) + val pattern = composeGetString(stringResource) + if (formatArgs.isNotEmpty()) { + @Suppress("SpreadOperator") + pattern.format(*formatArgs) + } else { + pattern + } } /** Retrieves a string from the [StringResource] in a suspending manner. */ @@ -44,6 +50,11 @@ suspend fun getStringSuspend(stringResource: StringResource, vararg formatArgs: } .toTypedArray() - @Suppress("SpreadOperator") - return composeGetString(stringResource, *resolvedArgs) + val pattern = composeGetString(stringResource) + return if (resolvedArgs.isNotEmpty()) { + @Suppress("SpreadOperator") + pattern.format(*resolvedArgs) + } else { + pattern + } } From ea555efe02cc92766d628fed12453336abc6bcdb Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:40:26 -0600 Subject: [PATCH 2/4] refactor: pass modifier to MapView and remove redundant Scaffold - Update `MapScreen` to pass layout modifiers to `MapView`. - Remove internal `Scaffold` from Google Maps `MapView` implementation. - Reformat `MapView.kt` for improved code style and readability. Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../org/meshtastic/feature/map/MapView.kt | 2 + .../org/meshtastic/feature/map/MapView.kt | 535 ++++++++++-------- .../org/meshtastic/feature/map/MapScreen.kt | 7 +- 3 files changed, 304 insertions(+), 240 deletions(-) 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 e0931fa210..d43d694407 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 @@ -231,6 +231,7 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) @Suppress("CyclomaticComplexMethod", "LongParameterList", "LongMethod") @Composable fun MapView( + modifier: Modifier = Modifier, mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit, focusedNodeNum: Int? = null, @@ -735,6 +736,7 @@ fun MapView( } Scaffold( + modifier = modifier, floatingActionButton = { DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { showCacheManagerDialog = true } }, 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 e23a6bcf60..62d152a672 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 @@ -43,7 +43,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -147,6 +146,7 @@ private const val TRACEROUTE_BOUNDS_PADDING_PX = 120 ) @Composable fun MapView( + modifier: Modifier = Modifier, mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit, focusedNodeNum: Int? = null, @@ -231,7 +231,10 @@ fun MapView( .build(), ) } else { - CameraUpdateFactory.newLatLngZoom(latLng, cameraPositionState.position.zoom) + CameraUpdateFactory.newLatLngZoom( + latLng, + cameraPositionState.position.zoom + ) } coroutineScope.launch { try { @@ -289,8 +292,8 @@ fun MapView( .filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num } .filter { node -> mapFilterState.lastHeardFilter.seconds == 0L || - (nowSeconds - node.lastHeard) <= mapFilterState.lastHeardFilter.seconds || - node.num == ourNodeInfo?.num + (nowSeconds - node.lastHeard) <= mapFilterState.lastHeardFilter.seconds || + node.num == ourNodeInfo?.num } val displayNodes = @@ -301,14 +304,20 @@ fun MapView( } LaunchedEffect(tracerouteOverlay, displayNodes) { if (tracerouteOverlay != null) { - onTracerouteMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size) + onTracerouteMappableCountChanged( + displayNodes.size, + tracerouteOverlay.relatedNodeNums.size + ) } } val myNodeNum = mapViewModel.myNodeNum val nodeClusterItems = displayNodes.map { node -> - val latLng = LatLng((node.position.latitude_i ?: 0) * DEG_D, (node.position.longitude_i ?: 0) * DEG_D) + val latLng = LatLng( + (node.position.latitude_i ?: 0) * DEG_D, + (node.position.longitude_i ?: 0) * DEG_D + ) NodeClusterItem( node = node, nodePosition = latLng, @@ -334,7 +343,8 @@ fun MapView( val tracerouteForwardPoints = remember(tracerouteOverlay, displayNodes) { val nodeLookup = displayNodes.associateBy { it.num } - tracerouteOverlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList() + tracerouteOverlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } + ?: emptyList() } val tracerouteReturnPoints = remember(tracerouteOverlay, displayNodes) { @@ -416,11 +426,17 @@ fun MapView( if (allPoints.isNotEmpty()) { val cameraUpdate = if (allPoints.size == 1) { - CameraUpdateFactory.newLatLngZoom(allPoints.first(), max(cameraPositionState.position.zoom, 12f)) + CameraUpdateFactory.newLatLngZoom( + allPoints.first(), + max(cameraPositionState.position.zoom, 12f) + ) } else { val bounds = LatLngBounds.builder() allPoints.forEach { bounds.include(it) } - CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX) + CameraUpdateFactory.newLatLngBounds( + bounds.build(), + TRACEROUTE_BOUNDS_PADDING_PX + ) } try { cameraPositionState.animate(cameraUpdate) @@ -431,13 +447,12 @@ fun MapView( } } - Scaffold { paddingValues -> - Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { - GoogleMap( - mapColorScheme = mapColorScheme, - modifier = Modifier.fillMaxSize(), - cameraPositionState = cameraPositionState, - uiSettings = + Box(modifier = modifier) { + GoogleMap( + mapColorScheme = mapColorScheme, + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + uiSettings = MapUiSettings( zoomControlsEnabled = true, mapToolbarEnabled = true, @@ -448,247 +463,271 @@ fun MapView( tiltGesturesEnabled = true, zoomGesturesEnabled = true, ), - properties = + properties = MapProperties( mapType = effectiveGoogleMapType, isMyLocationEnabled = - isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted, + isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted, ), - onMapLongClick = { latLng -> - if (isConnected) { - val newWaypoint = - Waypoint( - latitude_i = (latLng.latitude / DEG_D).toInt(), - longitude_i = (latLng.longitude / DEG_D).toInt(), - ) - editingWaypoint = newWaypoint - } - }, - ) { - key(currentCustomTileProviderUrl) { - currentCustomTileProviderUrl?.let { url -> - val config = - mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find { - it.urlTemplate == url || it.localUri == url - } - mapViewModel.getTileProvider(config)?.let { tileProvider -> - TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f) + onMapLongClick = { latLng -> + if (isConnected) { + val newWaypoint = + Waypoint( + latitude_i = (latLng.latitude / DEG_D).toInt(), + longitude_i = (latLng.longitude / DEG_D).toInt(), + ) + editingWaypoint = newWaypoint + } + }, + ) { + key(currentCustomTileProviderUrl) { + currentCustomTileProviderUrl?.let { url -> + val config = + mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find { + it.urlTemplate == url || it.localUri == url } + mapViewModel.getTileProvider(config)?.let { tileProvider -> + TileOverlay( + tileProvider = tileProvider, + fadeIn = true, + transparency = 0f, + zIndex = -1f + ) } } + } - if (tracerouteForwardPoints.size >= 2) { - Polyline( - points = tracerouteForwardOffsetPoints, - jointType = JointType.ROUND, - color = TracerouteColors.OutgoingRoute, - width = 9f, - zIndex = 3.0f, - ) - } - if (tracerouteReturnPoints.size >= 2) { - Polyline( - points = tracerouteReturnOffsetPoints, - jointType = JointType.ROUND, - color = TracerouteColors.ReturnRoute, - width = 7f, - zIndex = 2.5f, - ) - } + if (tracerouteForwardPoints.size >= 2) { + Polyline( + points = tracerouteForwardOffsetPoints, + jointType = JointType.ROUND, + color = TracerouteColors.OutgoingRoute, + width = 9f, + zIndex = 3.0f, + ) + } + if (tracerouteReturnPoints.size >= 2) { + Polyline( + points = tracerouteReturnOffsetPoints, + jointType = JointType.ROUND, + color = TracerouteColors.ReturnRoute, + width = 7f, + zIndex = 2.5f, + ) + } - if (nodeTracks != null && focusedNodeNum != null) { - val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter - val timeFilteredPositions = - nodeTracks.filter { - lastHeardTrackFilter == LastHeardFilter.Any || + if (nodeTracks != null && focusedNodeNum != null) { + val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter + val timeFilteredPositions = + nodeTracks.filter { + lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds - } - val sortedPositions = timeFilteredPositions.sortedBy { it.time } - allNodes - .find { it.num == focusedNodeNum } - ?.let { focusedNode -> - sortedPositions.forEachIndexed { index, position -> - key(position.time) { - val markerState = rememberUpdatedMarkerState(position = position.toLatLng()) - val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1)) - val color = Color(focusedNode.colors.second).copy(alpha = alpha) - val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite - val activeNodeZIndex = if (isHighPriority) 5f else 4f - - if (index == sortedPositions.lastIndex) { - MarkerComposable( - state = markerState, - zIndex = activeNodeZIndex, - alpha = if (isHighPriority) 1.0f else 0.9f, - ) { - NodeChip(node = focusedNode) - } - } else { - MarkerInfoWindowComposable( - state = markerState, - title = stringResource(Res.string.position), - snippet = formatAgo(position.time), - zIndex = 1f + alpha, - infoContent = { - PositionInfoWindowContent( - position = position, - displayUnits = displayUnits, - ) - }, - ) { - Icon( - imageVector = androidx.compose.material.icons.Icons.Rounded.TripOrigin, - contentDescription = stringResource(Res.string.track_point), - tint = color, + } + val sortedPositions = timeFilteredPositions.sortedBy { it.time } + allNodes + .find { it.num == focusedNodeNum } + ?.let { focusedNode -> + sortedPositions.forEachIndexed { index, position -> + key(position.time) { + val markerState = + rememberUpdatedMarkerState(position = position.toLatLng()) + val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1)) + val color = Color(focusedNode.colors.second).copy(alpha = alpha) + val isHighPriority = + focusedNode.num == myNodeNum || focusedNode.isFavorite + val activeNodeZIndex = if (isHighPriority) 5f else 4f + + if (index == sortedPositions.lastIndex) { + MarkerComposable( + state = markerState, + zIndex = activeNodeZIndex, + alpha = if (isHighPriority) 1.0f else 0.9f, + ) { + NodeChip(node = focusedNode) + } + } else { + MarkerInfoWindowComposable( + state = markerState, + title = stringResource(Res.string.position), + snippet = formatAgo(position.time), + zIndex = 1f + alpha, + infoContent = { + PositionInfoWindowContent( + position = position, + displayUnits = displayUnits, ) - } + }, + ) { + Icon( + imageVector = androidx.compose.material.icons.Icons.Rounded.TripOrigin, + contentDescription = stringResource(Res.string.track_point), + tint = color, + ) } } } + } - if (sortedPositions.size > 1) { - val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false) - segments.forEachIndexed { index, segmentPoints -> - val alpha = (index.toFloat() / (segments.size.toFloat() - 1)) - Polyline( - points = segmentPoints.map { it.toLatLng() }, - jointType = JointType.ROUND, - color = Color(focusedNode.colors.second).copy(alpha = alpha), - width = 8f, - zIndex = 0.6f, - ) - } + if (sortedPositions.size > 1) { + val segments = + sortedPositions.windowed(size = 2, step = 1, partialWindows = false) + segments.forEachIndexed { index, segmentPoints -> + val alpha = (index.toFloat() / (segments.size.toFloat() - 1)) + Polyline( + points = segmentPoints.map { it.toLatLng() }, + jointType = JointType.ROUND, + color = Color(focusedNode.colors.second).copy(alpha = alpha), + width = 8f, + zIndex = 0.6f, + ) } } - } else { - NodeClusterMarkers( - nodeClusterItems = nodeClusterItems, - mapFilterState = mapFilterState, - navigateToNodeDetails = navigateToNodeDetails, - onClusterClick = { cluster -> - val items = cluster.items.toList() - val allSameLocation = items.size > 1 && items.all { it.position == items.first().position } - - if (allSameLocation) { - showClusterItemsDialog = items - } else { - val bounds = LatLngBounds.builder() - cluster.items.forEach { bounds.include(it.position) } - coroutineScope.launch { - cameraPositionState.animate( - CameraUpdateFactory.newCameraPosition( - CameraPosition.Builder() - .target(bounds.build().center) - .zoom(cameraPositionState.position.zoom + 1) - .build(), - ), - ) - } - Logger.d { "Cluster clicked! $cluster" } - } - true - }, - ) - } - - WaypointMarkers( - displayableWaypoints = displayableWaypoints, + } + } else { + NodeClusterMarkers( + nodeClusterItems = nodeClusterItems, mapFilterState = mapFilterState, - myNodeNum = mapViewModel.myNodeNum ?: 0, - isConnected = isConnected, - unicodeEmojiToBitmapProvider = ::unicodeEmojiToBitmap, - onEditWaypointRequest = { waypointToEdit -> editingWaypoint = waypointToEdit }, - selectedWaypointId = selectedWaypointId, + navigateToNodeDetails = navigateToNodeDetails, + onClusterClick = { cluster -> + val items = cluster.items.toList() + val allSameLocation = + items.size > 1 && items.all { it.position == items.first().position } + + if (allSameLocation) { + showClusterItemsDialog = items + } else { + val bounds = LatLngBounds.builder() + cluster.items.forEach { bounds.include(it.position) } + coroutineScope.launch { + cameraPositionState.animate( + CameraUpdateFactory.newCameraPosition( + CameraPosition.Builder() + .target(bounds.build().center) + .zoom(cameraPositionState.position.zoom + 1) + .build(), + ), + ) + } + Logger.d { "Cluster clicked! $cluster" } + } + true + }, ) - - mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } } } - ScaleBar( - cameraPositionState = cameraPositionState, - modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 48.dp), + WaypointMarkers( + displayableWaypoints = displayableWaypoints, + mapFilterState = mapFilterState, + myNodeNum = mapViewModel.myNodeNum ?: 0, + isConnected = isConnected, + unicodeEmojiToBitmapProvider = ::unicodeEmojiToBitmap, + onEditWaypointRequest = { waypointToEdit -> editingWaypoint = waypointToEdit }, + selectedWaypointId = selectedWaypointId, ) - editingWaypoint?.let { waypointToEdit -> - EditWaypointDialog( - waypoint = waypointToEdit, - onSendClicked = { updatedWp -> - var finalWp = updatedWp - if (updatedWp.id == 0) { - finalWp = finalWp.copy(id = mapViewModel.generatePacketId() ?: 0) - } - if ((updatedWp.icon ?: 0) == 0) { - finalWp = finalWp.copy(icon = 0x1F4CD) - } - mapViewModel.sendWaypoint(finalWp) - editingWaypoint = null - }, - onDeleteClicked = { wpToDelete -> - if ((wpToDelete.locked_to ?: 0) == 0 && isConnected && wpToDelete.id != 0) { - val deleteMarkerWp = wpToDelete.copy(expire = 1) - mapViewModel.sendWaypoint(deleteMarkerWp) - } - mapViewModel.deleteWaypoint(wpToDelete.id) - editingWaypoint = null - }, - onDismissRequest = { editingWaypoint = null }, - ) + mapLayers.forEach { layerItem -> + key(layerItem.id) { + MapLayerOverlay( + layerItem, + mapViewModel + ) + } } + } - val visibleNetworkLayers = mapLayers.filter { it.isNetwork && it.isVisible } - val showRefresh = visibleNetworkLayers.isNotEmpty() - val isRefreshingLayers = visibleNetworkLayers.any { it.isRefreshing } - - MapControlsOverlay( - modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), - mapFilterMenuExpanded = mapFilterMenuExpanded, - onMapFilterMenuDismissRequest = { mapFilterMenuExpanded = false }, - onToggleMapFilterMenu = { mapFilterMenuExpanded = true }, - mapViewModel = mapViewModel, - mapTypeMenuExpanded = mapTypeMenuExpanded, - onMapTypeMenuDismissRequest = { mapTypeMenuExpanded = false }, - onToggleMapTypeMenu = { mapTypeMenuExpanded = true }, - onManageLayersClicked = { showLayersBottomSheet = true }, - onManageCustomTileProvidersClicked = { - mapTypeMenuExpanded = false - showCustomTileManagerSheet = true - }, - isNodeMap = focusedNodeNum != null, - isLocationTrackingEnabled = isLocationTrackingEnabled, - onToggleLocationTracking = { - if (locationPermissionsState.allPermissionsGranted) { - isLocationTrackingEnabled = !isLocationTrackingEnabled - if (!isLocationTrackingEnabled) { - followPhoneBearing = false - } - } else { - triggerLocationToggleAfterPermission = true - locationPermissionsState.launchMultiplePermissionRequest() + ScaleBar( + cameraPositionState = cameraPositionState, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(bottom = 48.dp), + ) + editingWaypoint?.let { waypointToEdit -> + EditWaypointDialog( + waypoint = waypointToEdit, + onSendClicked = { updatedWp -> + var finalWp = updatedWp + if (updatedWp.id == 0) { + finalWp = finalWp.copy(id = mapViewModel.generatePacketId() ?: 0) + } + if ((updatedWp.icon ?: 0) == 0) { + finalWp = finalWp.copy(icon = 0x1F4CD) } + + mapViewModel.sendWaypoint(finalWp) + editingWaypoint = null }, - bearing = cameraPositionState.position.bearing, - onCompassClick = { - if (isLocationTrackingEnabled) { - followPhoneBearing = !followPhoneBearing - } else { - coroutineScope.launch { - try { - val currentPosition = cameraPositionState.position - val newCameraPosition = CameraPosition.Builder(currentPosition).bearing(0f).build() - cameraPositionState.animate(CameraUpdateFactory.newCameraPosition(newCameraPosition)) - Logger.d { "Oriented map to north" } - } catch (e: IllegalStateException) { - Logger.d { "Error orienting map to north: ${e.message}" } - } - } + onDeleteClicked = { wpToDelete -> + if ((wpToDelete.locked_to ?: 0) == 0 && isConnected && wpToDelete.id != 0) { + val deleteMarkerWp = wpToDelete.copy(expire = 1) + mapViewModel.sendWaypoint(deleteMarkerWp) } + mapViewModel.deleteWaypoint(wpToDelete.id) + editingWaypoint = null }, - followPhoneBearing = followPhoneBearing, - showRefresh = showRefresh, - isRefreshing = isRefreshingLayers, - onRefresh = { mapViewModel.refreshAllVisibleNetworkLayers() }, + onDismissRequest = { editingWaypoint = null }, ) } + + val visibleNetworkLayers = mapLayers.filter { it.isNetwork && it.isVisible } + val showRefresh = visibleNetworkLayers.isNotEmpty() + val isRefreshingLayers = visibleNetworkLayers.any { it.isRefreshing } + + MapControlsOverlay( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 8.dp), + mapFilterMenuExpanded = mapFilterMenuExpanded, + onMapFilterMenuDismissRequest = { mapFilterMenuExpanded = false }, + onToggleMapFilterMenu = { mapFilterMenuExpanded = true }, + mapViewModel = mapViewModel, + mapTypeMenuExpanded = mapTypeMenuExpanded, + onMapTypeMenuDismissRequest = { mapTypeMenuExpanded = false }, + onToggleMapTypeMenu = { mapTypeMenuExpanded = true }, + onManageLayersClicked = { showLayersBottomSheet = true }, + onManageCustomTileProvidersClicked = { + mapTypeMenuExpanded = false + showCustomTileManagerSheet = true + }, + isNodeMap = focusedNodeNum != null, + isLocationTrackingEnabled = isLocationTrackingEnabled, + onToggleLocationTracking = { + if (locationPermissionsState.allPermissionsGranted) { + isLocationTrackingEnabled = !isLocationTrackingEnabled + if (!isLocationTrackingEnabled) { + followPhoneBearing = false + } + } else { + triggerLocationToggleAfterPermission = true + locationPermissionsState.launchMultiplePermissionRequest() + } + }, + bearing = cameraPositionState.position.bearing, + onCompassClick = { + if (isLocationTrackingEnabled) { + followPhoneBearing = !followPhoneBearing + } else { + coroutineScope.launch { + try { + val currentPosition = cameraPositionState.position + val newCameraPosition = + CameraPosition.Builder(currentPosition).bearing(0f).build() + cameraPositionState.animate( + CameraUpdateFactory.newCameraPosition( + newCameraPosition + ) + ) + Logger.d { "Oriented map to north" } + } catch (e: IllegalStateException) { + Logger.d { "Error orienting map to north: ${e.message}" } + } + } + } + }, + followPhoneBearing = followPhoneBearing, + showRefresh = showRefresh, + isRefreshing = isRefreshingLayers, + onRefresh = { mapViewModel.refreshAllVisibleNetworkLayers() }, + ) } if (showLayersBottomSheet) { ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) { @@ -731,7 +770,10 @@ private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) when (layerItem.layerType) { LayerType.KML -> KmlLayer(map, inputStream, context) LayerType.GEOJSON -> - GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() })) + GeoJsonLayer( + map, + JSONObject(inputStream.bufferedReader().use { it.readText() }) + ) } } catch (e: Exception) { Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" } @@ -797,7 +839,8 @@ fun Uri.getFileName(context: android.content.Context): String { if (this.scheme == "content") { context.contentResolver.query(this, null, null, null, null)?.use { cursor -> if (cursor.moveToFirst()) { - val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + val displayNameIndex = + cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) if (displayNameIndex != -1) { name = cursor.getString(displayNameIndex) } @@ -810,10 +853,16 @@ fun Uri.getFileName(context: android.content.Context): String { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @Suppress("LongMethod") -private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayUnits = DisplayUnits.METRIC) { +private fun PositionInfoWindowContent( + position: Position, + displayUnits: DisplayUnits = DisplayUnits.METRIC +) { @Composable fun PositionRow(label: String, value: String) { - Row(modifier = Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier.padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { Text(label, style = MaterialTheme.typography.labelMedium) Spacer(modifier = Modifier.width(16.dp)) Text(value, style = MaterialTheme.typography.labelMediumEmphasized) @@ -832,21 +881,30 @@ private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayU value = "%.5f".format((position.longitude_i ?: 0) * DEG_D), ) - PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view?.toString() ?: "") + PositionRow( + label = stringResource(Res.string.sats), + value = position.sats_in_view?.toString() ?: "" + ) PositionRow( label = stringResource(Res.string.alt), value = (position.altitude ?: 0).metersIn(displayUnits).toString(displayUnits), ) - PositionRow(label = stringResource(Res.string.speed), value = speedFromPosition(position, displayUnits)) + PositionRow( + label = stringResource(Res.string.speed), + value = speedFromPosition(position, displayUnits) + ) PositionRow( label = stringResource(Res.string.heading), value = "%.0f°".format((position.ground_track ?: 0) * HEADING_DEG), ) - PositionRow(label = stringResource(Res.string.timestamp), value = position.formatPositionTime()) + PositionRow( + label = stringResource(Res.string.timestamp), + value = position.formatPositionTime() + ) } } } @@ -868,11 +926,13 @@ private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): S return speedText } -internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) +internal fun Position.toLatLng(): LatLng = + LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) private fun Node.toLatLng(): LatLng? = this.position.toLatLng() -private fun Waypoint.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) +private fun Waypoint.toLatLng(): LatLng = + LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) private fun offsetPolyline( points: List, @@ -893,7 +953,10 @@ private fun offsetPolyline( headingPoints[headingPoints.lastIndex], ) - else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1]) + else -> SphericalUtil.computeHeading( + headingPoints[index - 1], + headingPoints[index + 1] + ) } } diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapScreen.kt index be5d93dcd4..e5fc66308c 100644 --- a/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -16,7 +16,7 @@ */ package org.meshtastic.feature.map -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable @@ -54,8 +54,7 @@ fun MapScreen( ) }, ) { paddingValues -> - Box(modifier = Modifier.padding(paddingValues)) { - MapView(mapViewModel = mapViewModel, navigateToNodeDetails = navigateToNodeDetails) - } + MapView(modifier= Modifier.fillMaxSize().padding(paddingValues), mapViewModel = mapViewModel, navigateToNodeDetails = navigateToNodeDetails) + } } From 2bf3a19deb60097d8db8678b9b2c755f6c91e558 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:15:51 -0600 Subject: [PATCH 3/4] ui: add safe drawing padding and recalculate insets to NavHost Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- app/src/main/java/com/geeksville/mesh/ui/Main.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index f41dcd8e1b..153b4dbacd 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -31,6 +31,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.recalculateWindowInsets +import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -445,7 +447,7 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: ScannerVie NavHost( navController = navController, startDestination = NodesRoutes.NodesGraph, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(), ) { contactsGraph(navController, uIViewModel.scrollToTopEventFlow) nodesGraph(navController, uIViewModel.scrollToTopEventFlow) From aeb84c8458c51893f64ba197ecd2a43059ee35f4 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:16:57 -0600 Subject: [PATCH 4/4] spotless Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../org/meshtastic/feature/map/MapView.kt | 157 +++++------------- .../org/meshtastic/feature/map/MapScreen.kt | 7 +- 2 files changed, 49 insertions(+), 115 deletions(-) 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 62d152a672..4820f51364 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 @@ -231,10 +231,7 @@ fun MapView( .build(), ) } else { - CameraUpdateFactory.newLatLngZoom( - latLng, - cameraPositionState.position.zoom - ) + CameraUpdateFactory.newLatLngZoom(latLng, cameraPositionState.position.zoom) } coroutineScope.launch { try { @@ -292,8 +289,8 @@ fun MapView( .filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num } .filter { node -> mapFilterState.lastHeardFilter.seconds == 0L || - (nowSeconds - node.lastHeard) <= mapFilterState.lastHeardFilter.seconds || - node.num == ourNodeInfo?.num + (nowSeconds - node.lastHeard) <= mapFilterState.lastHeardFilter.seconds || + node.num == ourNodeInfo?.num } val displayNodes = @@ -304,20 +301,14 @@ fun MapView( } LaunchedEffect(tracerouteOverlay, displayNodes) { if (tracerouteOverlay != null) { - onTracerouteMappableCountChanged( - displayNodes.size, - tracerouteOverlay.relatedNodeNums.size - ) + onTracerouteMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size) } } val myNodeNum = mapViewModel.myNodeNum val nodeClusterItems = displayNodes.map { node -> - val latLng = LatLng( - (node.position.latitude_i ?: 0) * DEG_D, - (node.position.longitude_i ?: 0) * DEG_D - ) + val latLng = LatLng((node.position.latitude_i ?: 0) * DEG_D, (node.position.longitude_i ?: 0) * DEG_D) NodeClusterItem( node = node, nodePosition = latLng, @@ -343,8 +334,7 @@ fun MapView( val tracerouteForwardPoints = remember(tracerouteOverlay, displayNodes) { val nodeLookup = displayNodes.associateBy { it.num } - tracerouteOverlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } - ?: emptyList() + tracerouteOverlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList() } val tracerouteReturnPoints = remember(tracerouteOverlay, displayNodes) { @@ -426,17 +416,11 @@ fun MapView( if (allPoints.isNotEmpty()) { val cameraUpdate = if (allPoints.size == 1) { - CameraUpdateFactory.newLatLngZoom( - allPoints.first(), - max(cameraPositionState.position.zoom, 12f) - ) + CameraUpdateFactory.newLatLngZoom(allPoints.first(), max(cameraPositionState.position.zoom, 12f)) } else { val bounds = LatLngBounds.builder() allPoints.forEach { bounds.include(it) } - CameraUpdateFactory.newLatLngBounds( - bounds.build(), - TRACEROUTE_BOUNDS_PADDING_PX - ) + CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX) } try { cameraPositionState.animate(cameraUpdate) @@ -453,22 +437,21 @@ fun MapView( modifier = Modifier.fillMaxSize(), cameraPositionState = cameraPositionState, uiSettings = - MapUiSettings( - zoomControlsEnabled = true, - mapToolbarEnabled = true, - compassEnabled = false, - myLocationButtonEnabled = false, - rotationGesturesEnabled = true, - scrollGesturesEnabled = true, - tiltGesturesEnabled = true, - zoomGesturesEnabled = true, - ), + MapUiSettings( + zoomControlsEnabled = true, + mapToolbarEnabled = true, + compassEnabled = false, + myLocationButtonEnabled = false, + rotationGesturesEnabled = true, + scrollGesturesEnabled = true, + tiltGesturesEnabled = true, + zoomGesturesEnabled = true, + ), properties = - MapProperties( - mapType = effectiveGoogleMapType, - isMyLocationEnabled = - isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted, - ), + MapProperties( + mapType = effectiveGoogleMapType, + isMyLocationEnabled = isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted, + ), onMapLongClick = { latLng -> if (isConnected) { val newWaypoint = @@ -487,12 +470,7 @@ fun MapView( it.urlTemplate == url || it.localUri == url } mapViewModel.getTileProvider(config)?.let { tileProvider -> - TileOverlay( - tileProvider = tileProvider, - fadeIn = true, - transparency = 0f, - zIndex = -1f - ) + TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f) } } } @@ -521,7 +499,7 @@ fun MapView( val timeFilteredPositions = nodeTracks.filter { lastHeardTrackFilter == LastHeardFilter.Any || - it.time > nowSeconds - lastHeardTrackFilter.seconds + it.time > nowSeconds - lastHeardTrackFilter.seconds } val sortedPositions = timeFilteredPositions.sortedBy { it.time } allNodes @@ -529,12 +507,10 @@ fun MapView( ?.let { focusedNode -> sortedPositions.forEachIndexed { index, position -> key(position.time) { - val markerState = - rememberUpdatedMarkerState(position = position.toLatLng()) + val markerState = rememberUpdatedMarkerState(position = position.toLatLng()) val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1)) val color = Color(focusedNode.colors.second).copy(alpha = alpha) - val isHighPriority = - focusedNode.num == myNodeNum || focusedNode.isFavorite + val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite val activeNodeZIndex = if (isHighPriority) 5f else 4f if (index == sortedPositions.lastIndex) { @@ -552,10 +528,7 @@ fun MapView( snippet = formatAgo(position.time), zIndex = 1f + alpha, infoContent = { - PositionInfoWindowContent( - position = position, - displayUnits = displayUnits, - ) + PositionInfoWindowContent(position = position, displayUnits = displayUnits) }, ) { Icon( @@ -569,8 +542,7 @@ fun MapView( } if (sortedPositions.size > 1) { - val segments = - sortedPositions.windowed(size = 2, step = 1, partialWindows = false) + val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false) segments.forEachIndexed { index, segmentPoints -> val alpha = (index.toFloat() / (segments.size.toFloat() - 1)) Polyline( @@ -590,8 +562,7 @@ fun MapView( navigateToNodeDetails = navigateToNodeDetails, onClusterClick = { cluster -> val items = cluster.items.toList() - val allSameLocation = - items.size > 1 && items.all { it.position == items.first().position } + val allSameLocation = items.size > 1 && items.all { it.position == items.first().position } if (allSameLocation) { showClusterItemsDialog = items @@ -625,21 +596,12 @@ fun MapView( selectedWaypointId = selectedWaypointId, ) - mapLayers.forEach { layerItem -> - key(layerItem.id) { - MapLayerOverlay( - layerItem, - mapViewModel - ) - } - } + mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } } } ScaleBar( cameraPositionState = cameraPositionState, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(bottom = 48.dp), + modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 48.dp), ) editingWaypoint?.let { waypointToEdit -> EditWaypointDialog( @@ -673,9 +635,7 @@ fun MapView( val isRefreshingLayers = visibleNetworkLayers.any { it.isRefreshing } MapControlsOverlay( - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = 8.dp), + modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), mapFilterMenuExpanded = mapFilterMenuExpanded, onMapFilterMenuDismissRequest = { mapFilterMenuExpanded = false }, onToggleMapFilterMenu = { mapFilterMenuExpanded = true }, @@ -709,13 +669,8 @@ fun MapView( coroutineScope.launch { try { val currentPosition = cameraPositionState.position - val newCameraPosition = - CameraPosition.Builder(currentPosition).bearing(0f).build() - cameraPositionState.animate( - CameraUpdateFactory.newCameraPosition( - newCameraPosition - ) - ) + val newCameraPosition = CameraPosition.Builder(currentPosition).bearing(0f).build() + cameraPositionState.animate(CameraUpdateFactory.newCameraPosition(newCameraPosition)) Logger.d { "Oriented map to north" } } catch (e: IllegalStateException) { Logger.d { "Error orienting map to north: ${e.message}" } @@ -770,10 +725,7 @@ private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) when (layerItem.layerType) { LayerType.KML -> KmlLayer(map, inputStream, context) LayerType.GEOJSON -> - GeoJsonLayer( - map, - JSONObject(inputStream.bufferedReader().use { it.readText() }) - ) + GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() })) } } catch (e: Exception) { Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" } @@ -839,8 +791,7 @@ fun Uri.getFileName(context: android.content.Context): String { if (this.scheme == "content") { context.contentResolver.query(this, null, null, null, null)?.use { cursor -> if (cursor.moveToFirst()) { - val displayNameIndex = - cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) if (displayNameIndex != -1) { name = cursor.getString(displayNameIndex) } @@ -853,16 +804,10 @@ fun Uri.getFileName(context: android.content.Context): String { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @Suppress("LongMethod") -private fun PositionInfoWindowContent( - position: Position, - displayUnits: DisplayUnits = DisplayUnits.METRIC -) { +private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayUnits = DisplayUnits.METRIC) { @Composable fun PositionRow(label: String, value: String) { - Row( - modifier = Modifier.padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { + Row(modifier = Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) { Text(label, style = MaterialTheme.typography.labelMedium) Spacer(modifier = Modifier.width(16.dp)) Text(value, style = MaterialTheme.typography.labelMediumEmphasized) @@ -881,30 +826,21 @@ private fun PositionInfoWindowContent( value = "%.5f".format((position.longitude_i ?: 0) * DEG_D), ) - PositionRow( - label = stringResource(Res.string.sats), - value = position.sats_in_view?.toString() ?: "" - ) + PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view?.toString() ?: "") PositionRow( label = stringResource(Res.string.alt), value = (position.altitude ?: 0).metersIn(displayUnits).toString(displayUnits), ) - PositionRow( - label = stringResource(Res.string.speed), - value = speedFromPosition(position, displayUnits) - ) + PositionRow(label = stringResource(Res.string.speed), value = speedFromPosition(position, displayUnits)) PositionRow( label = stringResource(Res.string.heading), value = "%.0f°".format((position.ground_track ?: 0) * HEADING_DEG), ) - PositionRow( - label = stringResource(Res.string.timestamp), - value = position.formatPositionTime() - ) + PositionRow(label = stringResource(Res.string.timestamp), value = position.formatPositionTime()) } } } @@ -926,13 +862,11 @@ private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): S return speedText } -internal fun Position.toLatLng(): LatLng = - LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) +internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) private fun Node.toLatLng(): LatLng? = this.position.toLatLng() -private fun Waypoint.toLatLng(): LatLng = - LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) +private fun Waypoint.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) private fun offsetPolyline( points: List, @@ -953,10 +887,7 @@ private fun offsetPolyline( headingPoints[headingPoints.lastIndex], ) - else -> SphericalUtil.computeHeading( - headingPoints[index - 1], - headingPoints[index + 1] - ) + else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1]) } } diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapScreen.kt index e5fc66308c..2dcfcfdabc 100644 --- a/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -54,7 +54,10 @@ fun MapScreen( ) }, ) { paddingValues -> - MapView(modifier= Modifier.fillMaxSize().padding(paddingValues), mapViewModel = mapViewModel, navigateToNodeDetails = navigateToNodeDetails) - + MapView( + modifier = Modifier.fillMaxSize().padding(paddingValues), + mapViewModel = mapViewModel, + navigateToNodeDetails = navigateToNodeDetails, + ) } }