diff --git a/Localizable.xcstrings b/Localizable.xcstrings index d4d38083..4114740d 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -5135,6 +5135,12 @@ } } } + }, + "Bearing: %@" : { + + }, + "Bearing: N/A" : { + }, "Biking" : { "localizations" : { @@ -8085,6 +8091,9 @@ } } } + }, + "Compass" : { + }, "Config" : { "localizations" : { @@ -11995,6 +12004,9 @@ } } } + }, + "Distance: %@" : { + }, "Documentation" : { "localizations" : { @@ -24767,6 +24779,9 @@ } } } + }, + "Open Compass" : { + }, "Open Settings" : { "localizations" : { @@ -42306,4 +42321,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 83759679..b87ce5f2 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -98,6 +98,7 @@ B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E905B02B71F7F300654D07 /* TextMessageField.swift */; }; BC10380F2DD4334400B00BFA /* AddContactIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */; }; BC6B45FF2CB2F98900723CEB /* SaveChannelSettingsIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC6B45FE2CB2F98900723CEB /* SaveChannelSettingsIntent.swift */; }; + BCA9A82C2EC802CF00166292 /* CompassView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA9A82B2EC802CF00166292 /* CompassView.swift */; }; BCB35B4F2E5FC42500B04F60 /* MessageNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB35B4E2E5FC41E00B04F60 /* MessageNodeIntent.swift */; }; BCB613812C67290800485544 /* SendWaypointIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613802C67290800485544 /* SendWaypointIntent.swift */; }; BCB613832C672A2600485544 /* MessageChannelIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613822C672A2600485544 /* MessageChannelIntent.swift */; }; @@ -409,6 +410,7 @@ B3E905B02B71F7F300654D07 /* TextMessageField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMessageField.swift; sourceTree = ""; }; BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactIntent.swift; sourceTree = ""; }; BC6B45FE2CB2F98900723CEB /* SaveChannelSettingsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveChannelSettingsIntent.swift; sourceTree = ""; }; + BCA9A82B2EC802CF00166292 /* CompassView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassView.swift; sourceTree = ""; }; BCB35B4E2E5FC41E00B04F60 /* MessageNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageNodeIntent.swift; sourceTree = ""; }; BCB613802C67290800485544 /* SendWaypointIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendWaypointIntent.swift; sourceTree = ""; }; BCB613822C672A2600485544 /* MessageChannelIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageChannelIntent.swift; sourceTree = ""; }; @@ -1274,6 +1276,7 @@ 8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */, 237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */, 23A1AFB62E42BD2500E46C96 /* RXTXIndicatorView.swift */, + BCA9A82B2EC802CF00166292 /* CompassView.swift */, ); path = Helpers; sourceTree = ""; @@ -1814,6 +1817,7 @@ 233E99BE2D849D3200CC3A77 /* RadiationCompactWidget.swift in Sources */, DDD94A502845C8F5004A87A0 /* DateTimeText.swift in Sources */, DDB6ABE228B13FB500384BA1 /* PositionConfigEnums.swift in Sources */, + BCA9A82C2EC802CF00166292 /* CompassView.swift in Sources */, DD994B69295F88B60013760A /* IntervalEnums.swift in Sources */, 23D316932E5618D2002FA4FB /* AsyncGate.swift in Sources */, 23FF00B62E323C75001DF095 /* AccessoryManager+Connect.swift in Sources */, diff --git a/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved index 53375016..d0b85617 100644 --- a/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fd71b247ba909b0eb360db5530e1068363839c5e169dea6f6a9974b2d98276f4", + "originHash" : "0fb5b226f8ca0b357ce7816ebac09d017cbe0ad253452876e5e2ff8e555c7124", "pins" : [ { "identity" : "cocoamqtt", diff --git a/Meshtastic/Helpers/LocationsHandler.swift b/Meshtastic/Helpers/LocationsHandler.swift index b174970a..6d44499d 100644 --- a/Meshtastic/Helpers/LocationsHandler.swift +++ b/Meshtastic/Helpers/LocationsHandler.swift @@ -26,6 +26,8 @@ import OSLog @Published var recordingStarted: Date? @Published var distanceTraveled = 0.0 @Published var elevationGain = 0.0 + @Published var heading: Double = 0.0 // Current heading in degrees + @Published var headingUpdatesStarted: Bool = false // Track heading updates state @Published var updatesStarted: Bool = UserDefaults.standard.bool(forKey: "liveUpdatesStarted") { @@ -131,6 +133,10 @@ import OSLog self.manager.desiredAccuracy = kCLLocationAccuracyBest // Set the distance filter to only receive updates when the device has moved a certain distance. self.manager.distanceFilter = kCLDistanceFilterNone // Receive all updates initially + if CLLocationManager.headingAvailable() { + self.manager.headingFilter = 1 // Update heading when it changes by 1 degree + self.manager.headingOrientation = .portrait // Adjust based on device orientation + } } func startLocationUpdates() { @@ -178,6 +184,39 @@ import OSLog // The Task completes implicitly here. } } + + // New method to start heading updates + func startHeadingUpdates() { + guard CLLocationManager.headingAvailable() else { + Logger.services.warning("📍 [App] Heading updates not available on this device.") + return + } + + guard manager.authorizationStatus == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse else { + Logger.services.warning("📍 [App] Cannot start heading updates: insufficient authorization status.") + return + } + + Logger.services.info("📍 [App] Starting heading updates") + manager.startUpdatingHeading() + headingUpdatesStarted = true + } + + // New method to stop heading updates + func stopHeadingUpdates() { + Logger.services.info("🛑 [App] Stopping heading updates") + manager.stopUpdatingHeading() + headingUpdatesStarted = false + } + + // Implement the CLLocationManagerDelegate method for heading updates + func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { + // Update heading on the main thread + Task { @MainActor in + self.heading = newHeading.trueHeading >= 0 ? newHeading.trueHeading : newHeading.magneticHeading + } + } + /// Stops receiving live location updates. func stopLocationUpdates() { Logger.services.info("🛑 [App] Stopping location updates") diff --git a/Meshtastic/Views/Helpers/CompassView.swift b/Meshtastic/Views/Helpers/CompassView.swift new file mode 100644 index 00000000..1e58b224 --- /dev/null +++ b/Meshtastic/Views/Helpers/CompassView.swift @@ -0,0 +1,295 @@ +// +// CompassView.swift +// Meshtastic +// +// Created by Benjamin Faershtein on 11/14/25. +// + +import SwiftUI +import CoreLocation +import UIKit + +struct CompassView: View { + + /// Single waypoint parameter + let waypointLocation: CLLocationCoordinate2D? + + let waypointName: String? + + let color: Color + + @ObservedObject private var locationsHandler = LocationsHandler.shared + + // Haptic alignment tracking + private let alignmentTolerance: Double = 5.0 + @State private var inAlignment = false + + // Compute bearing from user → waypoint + private func bearingToWaypoint() -> Double? { + guard + let waypoint = waypointLocation, + let user = LocationsHandler.currentLocation + else { return nil } + + return BearingCalculator.bearingBetween( + userLocation: user, + waypoint: waypoint + ) + } + + // Trigger a vibration if aligned with waypoint + private func checkAlignment(bearing: Double,heading: Double) { + // Compute minimal angular difference between heading and bearing in [0, 180] + let rawDiff = abs(heading - bearing).truncatingRemainder(dividingBy: 360) + let diff = min(rawDiff, 360 - rawDiff) + + if diff <= alignmentTolerance { + if !inAlignment { + inAlignment = true + let generator = UIImpactFeedbackGenerator(style: .heavy) + generator.impactOccurred() + } + } else { + inAlignment = false + } + } + + + private func distanceToWaypoint() -> CLLocationDistance? { + guard + let waypoint = waypointLocation, + let user = LocationsHandler.currentLocation + else { return nil } + + let userLocation = CLLocation(latitude: user.latitude, longitude: user.longitude) + let waypointLocation = CLLocation(latitude: waypoint.latitude, longitude: waypoint.longitude) + + return userLocation.distance(from: waypointLocation) + } + + // Format distance with localization + private func formatDistance(_ distance: CLLocationDistance) -> String { + let measurement = Measurement(value: distance, unit: UnitLength.meters) + let formatter = MeasurementFormatter() + formatter.unitOptions = .naturalScale + formatter.numberFormatter.maximumFractionDigits = 2 + return formatter.string(from: measurement) + } + + + var body: some View { + NavigationStack { + VStack(spacing: 15) { + + VStack(spacing: 8) { + Text(waypointName ?? "Waypoint") + .font(.title2) + .bold() + .foregroundColor(color) + + if let wp = waypointLocation { + HStack{ + Image(systemName: "mappin.and.ellipse") + Text("\(String(format: "%.4f", wp.latitude)), \(String(format: "%.4f", wp.longitude))") + .font(.subheadline) + } + + if let distance = distanceToWaypoint() { + HStack{ + Image(systemName: "lines.measurement.horizontal") + Text("Distance: \(formatDistance(distance))") + .font(.subheadline) + .fontWeight(.semibold) + } + } + HStack { + Image(systemName: "location.north") + if let bearing = bearingToWaypoint() { + Text("Bearing: \(String(format: "%.0f°", bearing))") + .font(.subheadline) + } else { + Text("Bearing: N/A") + .font(.subheadline) + } + } + } + } + .padding() + + Capsule() + .frame(width: 5, height: 50) + ZStack { + + // Cardinal/degree markers + ForEach(Marker.markers(), id: \.self) { marker in + CompassMarkerView( + marker: marker, + compassDegrees: -locationsHandler.heading + ) + } + + // Waypoint bearing indicator + if let bearing = bearingToWaypoint() { + WaypointMarkerView( + bearing: bearing, + compassDegrees: locationsHandler.heading, + color: color + ) + // Move waypoint marker outside compass + .onChange(of: locationsHandler.heading) { _, _ in + checkAlignment(bearing: bearing,heading:locationsHandler.heading) + } + } + + } + .frame(width: 300, height: 300) + .rotationEffect(Angle(degrees: -locationsHandler.heading)) + .statusBar(hidden: true) + .onAppear { + locationsHandler.startHeadingUpdates() + locationsHandler.startLocationUpdates() + } + .onDisappear { + locationsHandler.stopHeadingUpdates() + locationsHandler.stopLocationUpdates() + } + .navigationTitle("Compass") + } + } + } +} + + +// MARK: - Waypoint Marker View + +struct WaypointMarkerView: View { + let bearing: Double + let compassDegrees: Double + let color: Color + + var body: some View { + Circle() + .frame(width: 20, height: 20) + .foregroundColor(color) + .offset(y: -170) + .rotationEffect(Angle(degrees: bearing)) + } + +} + + +// MARK: - Bearing Calculator + +struct BearingCalculator { + + static func bearingBetween( + userLocation: CLLocationCoordinate2D, + waypoint: CLLocationCoordinate2D + ) -> Double { + + let lat1 = userLocation.latitude * .pi / 180 + let lon1 = userLocation.longitude * .pi / 180 + let lat2 = waypoint.latitude * .pi / 180 + let lon2 = waypoint.longitude * .pi / 180 + + let dLon = lon2 - lon1 + + let y = sin(dLon) * cos(lat2) + let x = cos(lat1) * sin(lat2) + - sin(lat1) * cos(lat2) * cos(dLon) + + var bearing = atan2(y, x) * 180 / .pi + if bearing < 0 { bearing += 360 } + + return bearing + } +} + + +// MARK: - Marker Model + +struct Marker: Hashable { + let degrees: Double + let label: String + + init(degrees: Double, label: String = "") { + self.degrees = degrees + self.label = label + } + + func degreeText() -> String { + return String(format: "%.0f", self.degrees) + } + + static func markers() -> [Marker] { + return [ + Marker(degrees: 0, label: "N"), + Marker(degrees: 30), + Marker(degrees: 60), + Marker(degrees: 90, label: "E"), + Marker(degrees: 120), + Marker(degrees: 150), + Marker(degrees: 180, label: "S"), + Marker(degrees: 210), + Marker(degrees: 240), + Marker(degrees: 270, label: "W"), + Marker(degrees: 300), + Marker(degrees: 330) + ] + } +} + + +// MARK: - Compass Marker View + +struct CompassMarkerView: View { + let marker: Marker + let compassDegrees: Double + + var body: some View { + VStack { + Text(marker.degreeText()) + .fontWeight(.light) + .rotationEffect(textAngle()) + + Capsule() + .frame(width: capsuleWidth(), height: capsuleHeight()) + .foregroundColor(capsuleColor()) + + Text(marker.label) + .fontWeight(.bold) + .rotationEffect(textAngle()) + .padding(.bottom, 180) + } + .rotationEffect(Angle(degrees: marker.degrees)) + } + + private func capsuleWidth() -> CGFloat { + marker.degrees == 0 ? 7 : 3 + } + + private func capsuleHeight() -> CGFloat { + marker.degrees == 0 ? 45 : 30 + } + + private func capsuleColor() -> Color { + marker.degrees == 0 ? .red : .gray + } + + private func textAngle() -> Angle { + Angle(degrees: -compassDegrees - marker.degrees) + } +} + + +// MARK: - Preview + +struct CompassView_Previews: PreviewProvider { + static var previews: some View { + CompassView( + waypointLocation: CLLocationCoordinate2D(latitude: 37.3346, longitude: -122.0090), + waypointName: "Apple Park", + color: Color.orange + ) + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift index 2e5e2809..8ab7b29f 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift @@ -19,6 +19,9 @@ struct PositionPopover: View { var position: PositionEntity var popover: Bool = true let distanceFormatter = MKDistanceFormatter() + + @State private var detentSelection: PresentationDetent = .fraction(0.65) + @State private var navigateToCompass = false var body: some View { // Node Color from node.num @@ -42,6 +45,19 @@ struct PositionPopover: View { Divider() HStack(alignment: .center) { VStack(alignment: .leading) { + Button { + detentSelection = .large + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + navigateToCompass = true + } + } label: { + HStack { + Image(systemName: "safari") + Text("Open Compass") + } + } + .padding(.bottom, 5) + /// Time Label { if idiom != .phone { @@ -131,6 +147,7 @@ struct PositionPopover: View { } .padding(.bottom, 5) } + /// Heading let degrees = Angle.degrees(Double(position.heading)) Label { @@ -234,10 +251,17 @@ struct PositionPopover: View { #endif } } + .presentationDetents([.fraction(0.65), .large], selection: $detentSelection) + .presentationContentInteraction(.scrolls) + .presentationDragIndicator(.visible) + .presentationBackgroundInteraction(.enabled(upThrough: .large)) + .navigationDestination(isPresented: $navigateToCompass) { + CompassView( + waypointLocation: position.coordinate, + waypointName: position.nodePosition?.user?.longName ?? "Unknown node", + color: (position.nodePosition?.user?.num != nil && position.nodePosition?.user?.num != 0) ? Color(UIColor(hex: UInt32(position.nodePosition!.user!.num))) : .orange + ) + } } - .presentationDetents([.fraction(0.65), .large]) - .presentationContentInteraction(.scrolls) - .presentationDragIndicator(.visible) - .presentationBackgroundInteraction(.enabled(upThrough: .large)) } } diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index 69e6ee4e..dc394f35 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -27,6 +27,7 @@ struct NodeDetail: View { var connectedNode: NodeInfoEntity? @ObservedObject var node: NodeInfoEntity @State private var environmentSectionHeight: CGFloat = 0 + @State var showingCompassSheet = false var body: some View { NavigationStack { @@ -473,6 +474,17 @@ struct NodeDetail: View { ) } if node.hasPositions { + #if !targetEnvironment(macCatalyst) + Button { + showingCompassSheet = true + } label: { + Label { + Text("Open Compass") + } icon: { + Image(systemName: "safari") + } + } + #endif NavigateToButton(node: node) } IgnoreNodeButton( @@ -559,6 +571,9 @@ struct NodeDetail: View { } } } + .sheet(isPresented: $showingCompassSheet) { + CompassView(waypointLocation: node.latestPosition?.nodeCoordinate ?? nil, waypointName: node.user?.longName ?? nil, color: Color(UIColor(hex: UInt32(node.num)))) + } .onAppear { scrollView.scrollTo("topOfList", anchor: .top) }