diff --git a/Localizable.xcstrings b/Localizable.xcstrings index e8c21ff6..e93f67a7 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -3118,6 +3118,13 @@ } } } + }, + "A previous position report for this node." : { + "comment" : "A description of a position history point.", + "isCommentAutoGenerated" : true + }, + "A previous position report showing the direction of travel." : { + }, "A red open lock means the channel is not securely encrypted and is used for precise location data, it uses either no key at all or a 1 byte known key." : { "localizations" : { @@ -3163,6 +3170,10 @@ } } }, + "A shared point of interest. Long-press the map to create one." : { + "comment" : "A description of a waypoint.", + "isCommentAutoGenerated" : true + }, "A Trace Route was sent, no response has been received." : { "localizations" : { "da" : { @@ -6009,6 +6020,10 @@ } } }, + "An outline enclosing all LoRa node positions on the mesh." : { + "comment" : "A description of the convex hull of a mesh.", + "isCommentAutoGenerated" : true + }, "Any missed messages will be delivered again." : { "localizations" : { "da" : { @@ -14154,6 +14169,10 @@ } } }, + "Dashed line showing a recorded route path." : { + "comment" : "A description of a dashed line that shows a recorded route path.", + "isCommentAutoGenerated" : true + }, "Date" : { "localizations" : { "da" : { @@ -25286,6 +25305,16 @@ } } } + }, + "Hide legend" : { + "comment" : "A label for a button that hides the map legend.", + "isCommentAutoGenerated" : true + }, + "Hide map legend" : { + + }, + "Hides the map legend." : { + }, "HIGH" : { "localizations" : { @@ -27578,6 +27607,10 @@ } } }, + "Indicates reduced GPS precision. The node is somewhere within the shaded area." : { + "comment" : "A description of a position precision circle.", + "isCommentAutoGenerated" : true + }, "Indoor Air Quality" : { "extractionState" : "stale", "localizations" : { @@ -30697,6 +30730,10 @@ } } }, + "Map Legend" : { + "comment" : "A title for a view that displays a map legend.", + "isCommentAutoGenerated" : true + }, "Map Options" : { "localizations" : { "da" : { @@ -31159,6 +31196,10 @@ } } }, + "Mesh Coverage" : { + "comment" : "A heading for the convex hull of the mesh.", + "isCommentAutoGenerated" : true + }, "Mesh Live Activity" : { "localizations" : { "da" : { @@ -35253,6 +35294,10 @@ } } }, + "Node heard within the last 2 hours. Shown with a pulsing ring on the map." : { + "comment" : "A description of the pulsing ring on the map.", + "isCommentAutoGenerated" : true + }, "Node History" : { "localizations" : { "da" : { @@ -35404,6 +35449,10 @@ } } }, + "Node not heard recently. Shown without a pulsing ring on the map." : { + "comment" : "A description of a node that is not heard by the user.", + "isCommentAutoGenerated" : true + }, "Node Number" : { "localizations" : { "da" : { @@ -35456,6 +35505,10 @@ } } }, + "Node with an active detection sensor module." : { + "comment" : "A description of a sensor node.", + "isCommentAutoGenerated" : true + }, "Nodes" : { "localizations" : { "da" : { @@ -36190,6 +36243,10 @@ } } }, + "Offline Node" : { + "comment" : "A description of an offline node.", + "isCommentAutoGenerated" : true + }, "Ok" : { "localizations" : { "es" : { @@ -36808,6 +36865,10 @@ } } }, + "Online Node" : { + "comment" : "A label displayed in the map legend for an online node.", + "isCommentAutoGenerated" : true + }, "Only permitted for SENSOR, TRACKER and TAK_TRACKER roles, this will inhibit all rebroadcasts, not unlike CLIENT_MUTE role." : { "extractionState" : "stale", "localizations" : { @@ -39546,6 +39607,14 @@ } } }, + "Position History" : { + "comment" : "A heading for the position history of a node.", + "isCommentAutoGenerated" : true + }, + "Position History Point" : { + "comment" : "A label displayed in the map legend for a position history point.", + "isCommentAutoGenerated" : true + }, "Position Log" : { "localizations" : { "da" : { @@ -39684,6 +39753,13 @@ } } }, + "Position Precision" : { + + }, + "Position Precision Circle" : { + "comment" : "A description of a map element that indicates reduced GPS precision.", + "isCommentAutoGenerated" : true + }, "Position Sent" : { "localizations" : { "da" : { @@ -39736,6 +39812,10 @@ } } }, + "Position with Heading" : { + "comment" : "A description of a position history point that shows the direction of travel.", + "isCommentAutoGenerated" : true + }, "Positions Enabled" : { "localizations" : { "da" : { @@ -44715,6 +44795,14 @@ } } }, + "Route End" : { + "comment" : "A label for a route end point on a map.", + "isCommentAutoGenerated" : true + }, + "Route Line" : { + "comment" : "A description of a route line on a map.", + "isCommentAutoGenerated" : true + }, "Route Lines" : { "localizations" : { "da" : { @@ -44893,6 +44981,10 @@ } } }, + "Route Start" : { + "comment" : "A label displayed for the start of a route.", + "isCommentAutoGenerated" : true + }, "Route: %@" : { "localizations" : { "da" : { @@ -50389,6 +50481,13 @@ } } } + }, + "Show legend" : { + "comment" : "A label for a button that shows/hides the map legend.", + "isCommentAutoGenerated" : true + }, + "Show map legend" : { + }, "Show nodes" : { "localizations" : { @@ -50608,6 +50707,9 @@ } } } + }, + "Shows the map legend." : { + }, "Shut Down" : { "localizations" : { @@ -56164,6 +56266,10 @@ } } }, + "Toggles the map legend" : { + "comment" : "A hint for the user to toggle the map legend.", + "isCommentAutoGenerated" : true + }, "Topic: %@" : { "extractionState" : "stale", "localizations" : { @@ -60865,6 +60971,10 @@ } } }, + "Waypoint" : { + "comment" : "A caption displayed underneath the name of a waypoint.", + "isCommentAutoGenerated" : true + }, "Waypoint Failed to Send" : { "localizations" : { "es" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 5038f261..79842eda 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -216,6 +216,7 @@ DD93800B2BA3F968008BEC06 /* NodeMapContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD93800A2BA3F968008BEC06 /* NodeMapContent.swift */; }; DD93800E2BA74D0C008BEC06 /* ChannelForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD93800D2BA74D0C008BEC06 /* ChannelForm.swift */; }; DD94B7402ACCE3BE00DCD1D1 /* MapSettingsForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD94B73F2ACCE3BE00DCD1D1 /* MapSettingsForm.swift */; }; + DD09240001E7FAD600E70001 /* MapLegend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD09240002E7FAD600E70001 /* MapLegend.swift */; }; DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD964FC1297272AE007C176F /* WaypointEntityExtension.swift */; }; DD964FC62975DBFD007C176F /* QueryCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD964FC52975DBFD007C176F /* QueryCoreData.swift */; }; DD97E96628EFD9820056DDA4 /* MeshtasticLogo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD97E96528EFD9820056DDA4 /* MeshtasticLogo.swift */; }; @@ -579,6 +580,7 @@ DD93800A2BA3F968008BEC06 /* NodeMapContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeMapContent.swift; sourceTree = ""; }; DD93800D2BA74D0C008BEC06 /* ChannelForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelForm.swift; sourceTree = ""; }; DD94B73F2ACCE3BE00DCD1D1 /* MapSettingsForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapSettingsForm.swift; sourceTree = ""; }; + DD09240002E7FAD600E70001 /* MapLegend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLegend.swift; sourceTree = ""; }; DD964FC029724F6D007C176F /* MeshtasticDataModelV6.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV6.xcdatamodel; sourceTree = ""; }; DD964FC1297272AE007C176F /* WaypointEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaypointEntityExtension.swift; sourceTree = ""; }; DD964FC52975DBFD007C176F /* QueryCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryCoreData.swift; sourceTree = ""; }; @@ -1181,6 +1183,7 @@ children = ( DDDC22362BA9232C002C44F1 /* MapContent */, 3D3417D32E2DC293006A988B /* MapDataFiles.swift */, + DD09240002E7FAD600E70001 /* MapLegend.swift */, DD94B73F2ACCE3BE00DCD1D1 /* MapSettingsForm.swift */, DDB6CCFA2AAF805100945AF6 /* NodeMapSwiftUI.swift */, DD13AA482AB73BF400BA0C98 /* PositionPopover.swift */, @@ -1804,6 +1807,7 @@ DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */, DDB6ABDB28B0AC6000384BA1 /* DistanceText.swift in Sources */, DD94B7402ACCE3BE00DCD1D1 /* MapSettingsForm.swift in Sources */, + DD09240001E7FAD600E70001 /* MapLegend.swift in Sources */, DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */, 23AD546B2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift in Sources */, 23F488122E32980B002C776F /* AccessoryManager+Position.swift in Sources */, diff --git a/Meshtastic/Extensions/View.swift b/Meshtastic/Extensions/View.swift index ec27882d..b28f5e8e 100644 --- a/Meshtastic/Extensions/View.swift +++ b/Meshtastic/Extensions/View.swift @@ -46,6 +46,18 @@ extension View { self } } + + @ViewBuilder + func glassButtonStyle() -> some View { + if #available(iOS 26.0, macOS 26.0, *) { + self.buttonStyle(.glass) + } else { + self + .tint(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .buttonStyle(.borderedProminent) + } + } } private struct FirstAppear: ViewModifier { diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift new file mode 100644 index 00000000..fd142007 --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift @@ -0,0 +1,277 @@ +// +// MapLegend.swift +// Meshtastic +// +// Implements a map legend overlay that explains the visual elements +// displayed on the map (issue #924). +// + +import SwiftUI + +struct MapLegendItem: View { + let symbol: AnyView + let title: String + let subtitle: String? + + init(symbol: AnyView, title: String, subtitle: String? = nil) { + self.symbol = symbol + self.title = title + self.subtitle = subtitle + } + + var body: some View { + HStack(spacing: 12) { + symbol + .frame(width: 40, height: 40) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline) + .fontWeight(.medium) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } + } +} + +struct MapLegend: View { + @Environment(\.dismiss) private var dismiss + let isMeshMap: Bool + + var body: some View { + NavigationStack { + List { + nodeSection + if isMeshMap { + waypointSection + } + precisionSection + if !isMeshMap { + historySection + } + routeSection + if isMeshMap { + convexHullSection + } + } + .navigationTitle("Map Legend") + .navigationBarTitleDisplayMode(.inline) + } + } + + // MARK: - Sections + + private var nodeSection: some View { + Section { + MapLegendItem( + symbol: AnyView(onlineNodeSymbol), + title: String(localized: "Online Node"), + subtitle: String(localized: "Node heard within the last 2 hours. Shown with a pulsing ring on the map.") + ) + MapLegendItem( + symbol: AnyView(offlineNodeSymbol), + title: String(localized: "Offline Node"), + subtitle: String(localized: "Node not heard recently. Shown without a pulsing ring on the map.") + ) + MapLegendItem( + symbol: AnyView(sensorNodeSymbol), + title: String(localized: "Detection Sensor"), + subtitle: String(localized: "Node with an active detection sensor module.") + ) + } header: { + Text("Nodes") + } + } + + private var waypointSection: some View { + Section { + MapLegendItem( + symbol: AnyView(waypointSymbol), + title: String(localized: "Waypoint"), + subtitle: String(localized: "A shared point of interest. Long-press the map to create one.") + ) + } header: { + Text("Waypoints") + } + } + + private var precisionSection: some View { + Section { + MapLegendItem( + symbol: AnyView(precisionCircleSymbol), + title: String(localized: "Position Precision Circle"), + subtitle: String(localized: "Indicates reduced GPS precision. The node is somewhere within the shaded area.") + ) + } header: { + Text("Position Precision") + } + } + + private var historySection: some View { + Section { + MapLegendItem( + symbol: AnyView(historyPointSymbol), + title: String(localized: "Position History Point"), + subtitle: String(localized: "A previous position report for this node.") + ) + MapLegendItem( + symbol: AnyView(historyArrowSymbol), + title: String(localized: "Position with Heading"), + subtitle: String(localized: "A previous position report showing the direction of travel.") + ) + } header: { + Text("Position History") + } + } + + private var routeSection: some View { + Section { + MapLegendItem( + symbol: AnyView(routeStartSymbol), + title: String(localized: "Route Start"), + subtitle: nil + ) + MapLegendItem( + symbol: AnyView(routeEndSymbol), + title: String(localized: "Route End"), + subtitle: nil + ) + MapLegendItem( + symbol: AnyView(routeLineSymbol), + title: String(localized: "Route Line"), + subtitle: String(localized: "Dashed line showing a recorded route path.") + ) + } header: { + Text("Routes") + } + } + + private var convexHullSection: some View { + Section { + MapLegendItem( + symbol: AnyView(convexHullSymbol), + title: String(localized: "Convex Hull"), + subtitle: String(localized: "An outline enclosing all LoRa node positions on the mesh.") + ) + } header: { + Text("Mesh Coverage") + } + } + + // MARK: - Symbols + + private var onlineNodeSymbol: some View { + ZStack { + Circle() + .fill(Color.green.opacity(0.3)) + .frame(width: 38, height: 38) + CircleText(text: "ON", color: .green, circleSize: 28) + } + } + + private var offlineNodeSymbol: some View { + CircleText(text: "OFF", color: .gray, circleSize: 28) + } + + private var sensorNodeSymbol: some View { + ZStack { + Circle() + .fill(Color.blue) + .frame(width: 28, height: 28) + Image(systemName: "sensor.fill") + .font(.system(size: 14)) + .foregroundStyle(.white) + } + } + + private var waypointSymbol: some View { + CircleText(text: "📍", color: .orange, circleSize: 28) + } + + private var precisionCircleSymbol: some View { + ZStack { + Circle() + .fill(Color.blue.opacity(0.25)) + .frame(width: 36, height: 36) + Circle() + .strokeBorder(Color.white, lineWidth: 1) + .frame(width: 36, height: 36) + Circle() + .fill(Color.blue) + .frame(width: 8, height: 8) + } + } + + private var historyPointSymbol: some View { + ZStack { + Circle() + .fill(Color.blue) + .frame(width: 12, height: 12) + Circle() + .stroke(Color.primary, lineWidth: 1) + .frame(width: 12, height: 12) + } + } + + private var historyArrowSymbol: some View { + Image(systemName: "location.north.circle.fill") + .font(.system(size: 20)) + .foregroundStyle(.blue) + } + + private var routeStartSymbol: some View { + Circle() + .fill(Color.green) + .strokeBorder(Color.white, lineWidth: 2) + .frame(width: 15, height: 15) + } + + private var routeEndSymbol: some View { + Circle() + .fill(Color.black) + .strokeBorder(Color.white, lineWidth: 2) + .frame(width: 15, height: 15) + } + + private var routeLineSymbol: some View { + ZStack { + Path { path in + path.move(to: CGPoint(x: 4, y: 20)) + path.addLine(to: CGPoint(x: 36, y: 20)) + } + .stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [6, 6])) + .foregroundStyle(Color.blue) + } + } + + private var convexHullSymbol: some View { + ZStack { + // Draw a simplified polygon shape + ConvexHullShape() + .fill(Color.indigo.opacity(0.4)) + ConvexHullShape() + .stroke(Color.blue, lineWidth: 2) + } + .frame(width: 32, height: 32) + } +} + +private struct ConvexHullShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let w = rect.width + let h = rect.height + path.move(to: CGPoint(x: w * 0.5, y: h * 0.1)) + path.addLine(to: CGPoint(x: w * 0.85, y: h * 0.3)) + path.addLine(to: CGPoint(x: w * 0.9, y: h * 0.7)) + path.addLine(to: CGPoint(x: w * 0.6, y: h * 0.9)) + path.addLine(to: CGPoint(x: w * 0.15, y: h * 0.8)) + path.addLine(to: CGPoint(x: w * 0.1, y: h * 0.35)) + path.closeSubpath() + return path + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift index 5181586c..09a08e34 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift @@ -52,6 +52,7 @@ struct NodeMapSwiftUI: View { @State var isLookingAround = false @State var isShowingAltitude = false @State var isEditingSettings = false + @State var isShowingLegend = false @State var isMeshMap = false @State var enabledOverlayConfigs: Set = Set() @@ -97,6 +98,13 @@ struct NodeMapSwiftUI: View { .sheet(isPresented: $isEditingSettings) { MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap, enabledOverlayConfigs: $enabledOverlayConfigs) } + .sheet(isPresented: $isShowingLegend) { + MapLegend(isMeshMap: false) + .presentationDetents([.medium, .large]) + .presentationContentInteraction(.scrolls) + .presentationDragIndicator(.visible) + .presentationBackgroundInteraction(.enabled(upThrough: .medium)) + } .onChange(of: selectedMapLayer) { _, newMapLayer in updateMapStyle(for: newMapLayer) } @@ -168,17 +176,25 @@ struct NodeMapSwiftUI: View { private var controlButtons: some View { HStack { + Button(action: { + withAnimation { + isShowingLegend = !isShowingLegend + } + }) { + Image(systemName: isShowingLegend ? "map.fill" : "map") + } + .accessibilityLabel(isShowingLegend ? Text("Hide legend") : Text("Show legend")) + .accessibilityHint(Text("Toggles the map legend")) + .glassButtonStyle() + Button(action: { withAnimation { isEditingSettings = !isEditingSettings } }) { Image(systemName: isEditingSettings ? "info.circle.fill" : "info.circle") - .padding(.vertical, 5) } - .tint(Color(UIColor.secondarySystemBackground)) - .foregroundColor(.accentColor) - .buttonStyle(.borderedProminent) + .glassButtonStyle() if scene != nil { Button(action: { @@ -188,11 +204,8 @@ struct NodeMapSwiftUI: View { isLookingAround = !isLookingAround }) { Image(systemName: isLookingAround ? "binoculars.fill" : "binoculars") - .padding(.vertical, 5) } - .tint(Color(UIColor.secondarySystemBackground)) - .foregroundColor(.accentColor) - .buttonStyle(.borderedProminent) + .glassButtonStyle() } if node.positions?.count ?? 0 > 1 { @@ -203,11 +216,8 @@ struct NodeMapSwiftUI: View { isShowingAltitude = !isShowingAltitude }) { Image(systemName: isShowingAltitude ? "mountain.2.fill" : "mountain.2") - .padding(.vertical, 5) } - .tint(Color(UIColor.secondarySystemBackground)) - .foregroundColor(.accentColor) - .buttonStyle(.borderedProminent) + .glassButtonStyle() } } .controlSize(.regular) diff --git a/Meshtastic/Views/Nodes/MeshMap.swift b/Meshtastic/Views/Nodes/MeshMap.swift index 6414eb3f..5ace2510 100644 --- a/Meshtastic/Views/Nodes/MeshMap.swift +++ b/Meshtastic/Views/Nodes/MeshMap.swift @@ -41,6 +41,7 @@ struct MeshMap: View { @State var selectedWaypointId: String? @State var newWaypointCoord: CLLocationCoordinate2D? @State var isMeshMap = true + @State private var showLegend = false /// Filter @StateObject var filters = NodeFilterParameters() @@ -158,20 +159,34 @@ struct MeshMap: View { filters: filters ) } + .sheet(isPresented: $showLegend) { + MapLegend(isMeshMap: true) + .presentationDetents([.medium, .large]) + .presentationContentInteraction(.scrolls) + .presentationDragIndicator(.visible) + .presentationBackgroundInteraction(.enabled(upThrough: .medium)) + } .safeAreaInset(edge: .bottom, alignment: .trailing) { HStack { Spacer() + Button(action: { + withAnimation { + showLegend = !showLegend + } + }) { + Image(systemName: showLegend ? "map.fill" : "map") + } + .accessibilityLabel(showLegend ? "Hide map legend" : "Show map legend") + .accessibilityHint(showLegend ? "Hides the map legend." : "Shows the map legend.") + .glassButtonStyle() Button(action: { withAnimation { editingSettings = !editingSettings } }) { Image(systemName: editingSettings ? "info.circle.fill" : "info.circle") - .padding(.vertical, 5) } - .tint(Color(UIColor.secondarySystemBackground)) - .foregroundColor(.accentColor) - .buttonStyle(.borderedProminent) + .glassButtonStyle() } .controlSize(.regular) .padding(5) diff --git a/Meshtastic/Views/Settings/RouteRecorder.swift b/Meshtastic/Views/Settings/RouteRecorder.swift index 6a0fc539..b1c63bc2 100644 --- a/Meshtastic/Views/Settings/RouteRecorder.swift +++ b/Meshtastic/Views/Settings/RouteRecorder.swift @@ -74,8 +74,7 @@ struct RouteRecorder: View { .symbolRenderingMode(.multicolor) .foregroundColor(.red) } - .buttonStyle(.bordered) - .foregroundColor(.red) + .glassButtonStyle() .buttonBorderShape(.circle) .matchedGeometryEffect(id: "Details Button", in: namespace) diff --git a/Meshtastic/Views/Settings/Routes.swift b/Meshtastic/Views/Settings/Routes.swift index a90533df..3db1cc4f 100644 --- a/Meshtastic/Views/Settings/Routes.swift +++ b/Meshtastic/Views/Settings/Routes.swift @@ -281,7 +281,7 @@ struct Routes: View { } label: { Label("Export", systemImage: "square.and.arrow.down") } - .buttonStyle(.bordered) + .glassButtonStyle() .buttonBorderShape(.capsule) .controlSize(.large) .padding(.bottom)