Skip to content

Commit c222cfb

Browse files
jamesarichCopilot
andcommitted
feat(map): add node info bottom sheet on map tap
Show a compact bottom sheet with node name, last heard, battery, and signal info when tapping a node marker. Users can tap 'View Details' to navigate to the full node detail screen, preserving map context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d151b71 commit c222cfb

4 files changed

Lines changed: 111 additions & 1 deletion

File tree

.skills/compose-ui/strings-index.txt

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/resources/src/commonMain/composeResources/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,6 +1324,7 @@
13241324
<string name="via_api">via API</string>
13251325
<string name="via_mqtt">via MQTT</string>
13261326
<string name="via_udp">via UDP</string>
1327+
<string name="view_details">View Details</string>
13271328
<string name="view_on_map">View on map</string>
13281329
<string name="view_release">View Release</string>
13291330
<string name="voltage">Voltage</string>

feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import org.meshtastic.feature.map.component.MapEmptyState
6161
import org.meshtastic.feature.map.component.MapFilterDropdown
6262
import org.meshtastic.feature.map.component.MapStyleSelector
6363
import org.meshtastic.feature.map.component.MaplibreMapContent
64+
import org.meshtastic.feature.map.component.NodeInfoSheet
6465
import org.meshtastic.feature.map.model.MapStyle
6566
import org.meshtastic.feature.map.util.computeBoundingBox
6667
import org.meshtastic.feature.map.util.toGeoPositionOrNull
@@ -104,6 +105,9 @@ fun MapScreen(
104105

105106
var filterMenuExpanded by remember { mutableStateOf(false) }
106107

108+
// Node info sheet state
109+
var selectedNodeNum by remember { mutableStateOf<Int?>(null) }
110+
107111
// Waypoint dialog state
108112
var showWaypointDialog by remember { mutableStateOf(false) }
109113
var longPressPosition by remember { mutableStateOf<GeoPosition?>(null) }
@@ -184,7 +188,7 @@ fun MapScreen(
184188
showWaypoints = filterState.showWaypoints,
185189
showPrecisionCircle = filterState.showPrecisionCircle,
186190
showHillshade = selectedMapStyle == MapStyle.Terrain,
187-
onNodeClick = { nodeNum -> navigateToNodeDetails(nodeNum) },
191+
onNodeClick = { nodeNum -> selectedNodeNum = nodeNum },
188192
onMapLongClick = { position ->
189193
longPressPosition = position
190194
editingWaypointId = null
@@ -359,4 +363,17 @@ fun MapScreen(
359363
position = longPressPosition,
360364
)
361365
}
366+
367+
// Node info bottom sheet
368+
val selectedNode = selectedNodeNum?.let { num -> filteredNodes.find { it.num == num } }
369+
if (selectedNode != null) {
370+
NodeInfoSheet(
371+
node = selectedNode,
372+
onDismiss = { selectedNodeNum = null },
373+
onViewDetails = { nodeNum ->
374+
selectedNodeNum = null
375+
navigateToNodeDetails(nodeNum)
376+
},
377+
)
378+
}
362379
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.feature.map.component
18+
19+
import androidx.compose.foundation.layout.Arrangement
20+
import androidx.compose.foundation.layout.Column
21+
import androidx.compose.foundation.layout.Row
22+
import androidx.compose.foundation.layout.Spacer
23+
import androidx.compose.foundation.layout.fillMaxWidth
24+
import androidx.compose.foundation.layout.height
25+
import androidx.compose.foundation.layout.padding
26+
import androidx.compose.material3.Button
27+
import androidx.compose.material3.ExperimentalMaterial3Api
28+
import androidx.compose.material3.MaterialTheme
29+
import androidx.compose.material3.ModalBottomSheet
30+
import androidx.compose.material3.Text
31+
import androidx.compose.material3.rememberModalBottomSheetState
32+
import androidx.compose.runtime.Composable
33+
import androidx.compose.ui.Alignment
34+
import androidx.compose.ui.Modifier
35+
import androidx.compose.ui.unit.dp
36+
import org.jetbrains.compose.resources.stringResource
37+
import org.meshtastic.core.model.Node
38+
import org.meshtastic.core.resources.Res
39+
import org.meshtastic.core.resources.view_details
40+
import org.meshtastic.core.ui.component.LastHeardInfo
41+
import org.meshtastic.core.ui.component.MaterialBatteryInfo
42+
import org.meshtastic.core.ui.component.SignalInfo
43+
44+
/**
45+
* A modal bottom sheet showing a compact summary of a node when tapped on the map. Provides quick info (name, last
46+
* heard, battery, signal) and a button to navigate to full details.
47+
*/
48+
@OptIn(ExperimentalMaterial3Api::class)
49+
@Composable
50+
internal fun NodeInfoSheet(node: Node, onDismiss: () -> Unit, onViewDetails: (Int) -> Unit) {
51+
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
52+
53+
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
54+
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp)) {
55+
// Node name
56+
Text(
57+
text = node.user.long_name.ifBlank { node.user.short_name },
58+
style = MaterialTheme.typography.titleLarge,
59+
)
60+
if (node.user.long_name.isNotBlank() && node.user.short_name.isNotBlank()) {
61+
Text(
62+
text = node.user.short_name,
63+
style = MaterialTheme.typography.bodyMedium,
64+
color = MaterialTheme.colorScheme.onSurfaceVariant,
65+
)
66+
}
67+
68+
Spacer(Modifier.height(16.dp))
69+
70+
// Info row: last heard, battery, signal
71+
Row(
72+
modifier = Modifier.fillMaxWidth(),
73+
horizontalArrangement = Arrangement.spacedBy(16.dp),
74+
verticalAlignment = Alignment.CenterVertically,
75+
) {
76+
LastHeardInfo(lastHeard = node.lastHeard, showLabel = false)
77+
MaterialBatteryInfo(level = node.batteryLevel)
78+
SignalInfo(node = node)
79+
}
80+
81+
Spacer(Modifier.height(24.dp))
82+
83+
// View details button
84+
Button(onClick = { onViewDetails(node.num) }, modifier = Modifier.fillMaxWidth()) {
85+
Text(stringResource(Res.string.view_details))
86+
}
87+
88+
Spacer(Modifier.height(8.dp))
89+
}
90+
}
91+
}

0 commit comments

Comments
 (0)