-
-
Notifications
You must be signed in to change notification settings - Fork 270
Expand file tree
/
Copy pathMapScreen.swift
More file actions
172 lines (157 loc) · 4.58 KB
/
Copy pathMapScreen.swift
File metadata and controls
172 lines (157 loc) · 4.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
//
// MapScreen.swift
// Meshtastic TV
//
// Copyright(c) Garth Vander Houwen 7/24/26.
//
// Live mesh map with a focusable node side-list for Siri Remote operation.
// The list is the primary, reliable selection path on tvOS; clicking a pin on
// the map (focus engine) selects too and keeps the list in sync. Rows carry the
// same node-color circle badges as the map pins (CircleText, shared with iOS).
//
import SwiftUI
import SwiftData
struct MapScreen: View {
@Bindable var client: MeshClient
@State private var selectedNodeNum: UInt32?
@State private var recenterToken = 0
// Row focus is the browse signal on tvOS: List(selection:) does not follow
// focus for NavigationLink rows, so track it explicitly and mirror it into
// the map selection (debounced map-side).
@FocusState private var focusedNodeNum: UInt32?
@State private var navPath: [UInt32] = []
/// The persisted node store — the map and list read from here, not the client.
@Query private var allNodes: [MeshNode]
/// All nodes for the side list: located first, then alphabetically.
private var sortedNodes: [MeshNode] {
allNodes.sorted {
if $0.hasLocation != $1.hasLocation { return $0.hasLocation }
return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
}
}
/// Located nodes, most-recently-heard first — the map's data source.
private var locatedNodes: [MeshNode] {
allNodes.filter(\.hasLocation)
.sorted { ($0.lastHeard ?? .distantPast) > ($1.lastHeard ?? .distantPast) }
}
var body: some View {
HStack(spacing: 0) {
nodeList
.frame(width: 520)
MeshTVMapView(
nodes: locatedNodes,
selectedNodeNum: $selectedNodeNum,
recenterToken: recenterToken,
onMenuExit: escapeMap
)
.ignoresSafeArea()
}
}
/// Menu pressed while the map held focus: pop any open node detail and hand
/// focus back to the node list — without this, MKMapView is a focus trap.
private func escapeMap() {
navPath = []
focusedNodeNum = selectedNodeNum ?? sortedNodes.first?.num
}
private var nodeList: some View {
NavigationStack(path: $navPath) {
List {
// Map controls live in the list column — the overlay buttons were
// unreachable by remote once the map started capturing focus.
Section {
Button {
selectedNodeNum = nil
recenterToken += 1
} label: {
Label("Re-center Map", systemImage: "scope")
}
NavigationLink {
SettingsView()
} label: {
Label("Settings", systemImage: "gearshape")
}
Button(role: .destructive) {
client.disconnect()
} label: {
Label("Disconnect", systemImage: "xmark.circle.fill")
}
}
Section {
ForEach(sortedNodes) { node in
NavigationLink(value: node.num) {
NodeRow(node: node)
}
.focused($focusedNodeNum, equals: node.num)
}
} header: {
Text("\(allNodes.count) nodes · \(locatedNodes.count) on map")
}
}
.onChange(of: focusedNodeNum) { _, newValue in
if let newValue { selectedNodeNum = newValue }
}
.navigationDestination(for: UInt32.self) { num in
if let node = allNodes.first(where: { $0.num == num }) {
NodeDetailView(node: node)
}
}
.safeAreaInset(edge: .top) {
header
}
}
}
private var header: some View {
HStack(spacing: 20) {
VStack(alignment: .leading, spacing: 6) {
Image("meshtastic-wordmark-white")
.resizable()
.scaledToFit()
.frame(height: 30)
Text(client.host)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal, 32)
.padding(.vertical, 16)
}
private var disconnectButton: some View {
Button(role: .destructive) {
client.disconnect()
} label: {
Label("Disconnect", systemImage: "xmark.circle.fill")
}
.buttonStyle(.bordered)
}
}
private struct NodeRow: View {
let node: MeshNode
var body: some View {
HStack(spacing: 16) {
// Same circle badge as the map pin (node color + short name).
CircleText(
text: node.shortName.isEmpty ? "?" : node.shortName,
color: Color(UIColor(hex: node.num)),
circleSize: 52
)
.opacity(node.hasLocation ? 1 : 0.45)
VStack(alignment: .leading, spacing: 4) {
Text(node.displayName)
.lineLimit(1)
HStack(spacing: 12) {
if let battery = node.batteryLevel {
Label("\(battery)%", systemImage: battery > 20 ? "battery.75percent" : "battery.25percent")
.foregroundStyle(Color("LightIndigo"))
}
if !node.hasLocation {
Label("No position", systemImage: "location.slash")
.foregroundStyle(.secondary)
}
}
.font(.caption)
}
Spacer()
}
}
}