diff --git a/Meshtastic/Assets.xcassets/SOLAR_NODE.imageset/Contents.json b/Meshtastic/Assets.xcassets/SOLAR_NODE.imageset/Contents.json new file mode 100644 index 00000000..af90d4b0 --- /dev/null +++ b/Meshtastic/Assets.xcassets/SOLAR_NODE.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "solarnode.jpeg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "solarnode 1.jpeg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "solar_node.jpeg", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Meshtastic/Assets.xcassets/SOLAR_NODE.imageset/solar_node.jpeg b/Meshtastic/Assets.xcassets/SOLAR_NODE.imageset/solar_node.jpeg new file mode 100644 index 00000000..bb84f38f Binary files /dev/null and b/Meshtastic/Assets.xcassets/SOLAR_NODE.imageset/solar_node.jpeg differ diff --git a/Meshtastic/Assets.xcassets/SOLAR_NODE.imageset/solarnode 1.jpeg b/Meshtastic/Assets.xcassets/SOLAR_NODE.imageset/solarnode 1.jpeg new file mode 100644 index 00000000..547de73b Binary files /dev/null and b/Meshtastic/Assets.xcassets/SOLAR_NODE.imageset/solarnode 1.jpeg differ diff --git a/Meshtastic/Assets.xcassets/SOLAR_NODE.imageset/solarnode.jpeg b/Meshtastic/Assets.xcassets/SOLAR_NODE.imageset/solarnode.jpeg new file mode 100644 index 00000000..547de73b Binary files /dev/null and b/Meshtastic/Assets.xcassets/SOLAR_NODE.imageset/solarnode.jpeg differ diff --git a/Meshtastic/Helpers/LocationsHandler.swift b/Meshtastic/Helpers/LocationsHandler.swift index 7e172139..56e1ca18 100644 --- a/Meshtastic/Helpers/LocationsHandler.swift +++ b/Meshtastic/Helpers/LocationsHandler.swift @@ -16,12 +16,16 @@ import CoreLocation static let shared = LocationsHandler() // Create a single, shared instance of the object. private let manager: CLLocationManager private var background: CLBackgroundActivitySession? - var locationsArray: [CLLocation] var enableSmartPosition: Bool - //@Published var lastLocation = CLLocation() + @Published var locationsArray: [CLLocation] @Published var isStationary = false @Published var count = 0 + @Published var isRecording = false + @Published var isRecordingPaused = false + @Published var recordingStarted: Date? + @Published var distanceTraveled = 0.0 + @Published var elevationGain = 0.0 @Published var updatesStarted: Bool = UserDefaults.standard.bool(forKey: "liveUpdatesStarted") { @@ -95,6 +99,16 @@ import CoreLocation print("Bad Location \(self.count): Horizontal Accuracy: \(location.horizontalAccuracy) \(location)") return false } + if isRecording { + if let lastLocation = locationsArray.last { + let distance = location.distance(from: lastLocation) + let gain = location.altitude - lastLocation.altitude + distanceTraveled += distance + if gain > 0 { + elevationGain += gain + } + } + } locationsArray.append(location) return true } @@ -103,7 +117,7 @@ import CoreLocation static var satsInView: Int { var sats = 0 - if let newLocation = shared.locationsArray.last{ + if let newLocation = shared.locationsArray.last { sats = 1 if newLocation.verticalAccuracy > 0 { sats = 4 diff --git a/Meshtastic/Views/Nodes/PositionLog.swift b/Meshtastic/Views/Nodes/PositionLog.swift index 33d2d92d..f4979cb7 100644 --- a/Meshtastic/Views/Nodes/PositionLog.swift +++ b/Meshtastic/Views/Nodes/PositionLog.swift @@ -47,12 +47,12 @@ struct PositionLog: View { } TableColumn("Speed") { position in let speed = Measurement(value: Double(position.speed), unit: UnitSpeed.kilometersPerHour) - Text(speed.formatted()) + Text(speed.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0))))) } TableColumn("Heading") { position in let degrees = Angle.degrees(Double(position.heading)) let heading = Measurement(value: degrees.degrees, unit: UnitAngle.degrees) - Text("\(heading.formatted())") + Text(heading.formatted(.measurement(width: .narrow, numberFormatStyle: .number.precision(.fractionLength(0))))) } TableColumn("SNR") { position in Text("\(String(format: "%.2f", position.snr)) dB") diff --git a/Meshtastic/Views/Settings/About.swift b/Meshtastic/Views/Settings/About.swift index b395a9c0..4bcb44e8 100644 --- a/Meshtastic/Views/Settings/About.swift +++ b/Meshtastic/Views/Settings/About.swift @@ -17,11 +17,32 @@ struct AboutMeshtastic: View { List { Section(header: Text("What is Meshtastic?")) { - Text("An open source, off-grid, decentralized, mesh network built to run on affordable, low-power devices.") + Text("An open source, off-grid, decentralized, mesh network that runs on affordable, low-power radios.") .font(.title3) } Section(header: Text("Apple Apps")) { + + if locale.region?.identifier ?? "US" == "US" { + HStack { + Image("SOLAR_NODE") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 75) + .cornerRadius(5) + .padding() + VStack(alignment: .leading) { + Link("Buy Complete Radios", destination: URL(string: "http://garthvh.com")!) + .font(.title2) + Text("Get custom waterproof solar and detection sensor router nodes, aluminium desktop nodes and rugged handsets.") + .font(.callout) + } + } + } + Link("Sponsor App Development", destination: URL(string: "https://github.com/sponsors/garthvh")!) + .font(.title2) + Link("GitHub Repository", destination: URL(string: "https://github.com/meshtastic/Meshtastic-Apple")!) + .font(.title2) Button("Review the app") { if let scene = UIApplication.shared.connectedScenes .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene { @@ -29,17 +50,8 @@ struct AboutMeshtastic: View { } } .font(.title2) - Link("Sponsor App Development", destination: URL(string: "https://github.com/sponsors/garthvh")!) - .font(.title2) - Link("GitHub Repository", destination: URL(string: "https://github.com/meshtastic/Meshtastic-Apple")!) - .font(.title2) - } - if locale.region?.identifier ?? "no locale" == "US" { - Section(header: Text("Get Devices")) { - Link("Buy Complete Radios", destination: URL(string: "http://garthvh.com")!) - .font(.title2) - } } + Section(header: Text("Project information")) { Link("Website", destination: URL(string: "https://meshtastic.org")!) .font(.title2) diff --git a/Meshtastic/Views/Settings/GPSStatus.swift b/Meshtastic/Views/Settings/GPSStatus.swift index 931a7167..b9e18679 100644 --- a/Meshtastic/Views/Settings/GPSStatus.swift +++ b/Meshtastic/Views/Settings/GPSStatus.swift @@ -11,33 +11,37 @@ import CoreLocation @available(iOS 17.0, macOS 14.0, *) struct GPSStatus: View { + var largeFont: Font = .footnote + var smallFont: Font = .caption2 + @ObservedObject var locationsHandler: LocationsHandler = LocationsHandler.shared var body: some View { if let newLocation = locationsHandler.locationsArray.last { - let horizontalAccuracy = Measurement(value: newLocation.horizontalAccuracy, unit: UnitLength.meters) - let verticalAccuracy = Measurement(value: newLocation.verticalAccuracy, unit: UnitLength.meters) - let altitiude = Measurement(value: newLocation.altitude, unit: UnitLength.meters) - let speed = Measurement(value: newLocation.speed, unit: UnitSpeed.kilometersPerHour) - let speedAccuracy = Measurement(value: newLocation.speedAccuracy, unit: UnitSpeed.metersPerSecond) - let courseAccuracy = Measurement(value: newLocation.courseAccuracy, unit: UnitAngle.degrees) + let horizontalAccuracy = Measurement(value: newLocation.horizontalAccuracy, unit: UnitLength.meters) + let verticalAccuracy = Measurement(value: newLocation.verticalAccuracy, unit: UnitLength.meters) + let altitiude = Measurement(value: newLocation.altitude, unit: UnitLength.meters) + let speed = Measurement(value: newLocation.speed, unit: UnitSpeed.kilometersPerHour) + let speedAccuracy = Measurement(value: newLocation.speedAccuracy, unit: UnitSpeed.metersPerSecond) + let courseAccuracy = Measurement(value: newLocation.courseAccuracy, unit: UnitAngle.degrees) + Label("Coordinate \(String(format: "%.5f", newLocation.coordinate.latitude)), \(String(format: "%.5f", newLocation.coordinate.longitude))", systemImage: "mappin") - .font(.footnote) + .font(largeFont) .textSelection(.enabled) HStack { Label("Accuracy \(horizontalAccuracy.formatted())", systemImage: "scope") - .font(.footnote) + .font(largeFont) Label("Sats Estimate \(LocationsHandler.satsInView)", systemImage: "sparkles") - .font(.footnote) + .font(largeFont) } HStack { if newLocation.verticalAccuracy > 0 { Label("Altitude \(altitiude.formatted())", systemImage: "mountain.2") - .font(.footnote) + .font(largeFont) } Label("Accuracy \(verticalAccuracy.formatted())", systemImage: "lines.measurement.vertical") - .font(.caption2) + .font(smallFont) } HStack { let degrees = Angle.degrees(newLocation.course) @@ -49,15 +53,15 @@ struct GPSStatus: View { .symbolRenderingMode(.hierarchical) .rotationEffect(degrees) } - .font(.footnote) + .font(largeFont) Label("Accuracy \(courseAccuracy.formatted(.measurement(width: .narrow, numberFormatStyle: .number.precision(.fractionLength(0)))))", systemImage: "safari") - .font(.caption2) + .font(smallFont) } HStack { Label("Speed \(speed.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))))", systemImage: "speedometer") - .font(.footnote) + .font(largeFont) Label("Accuracy \(speedAccuracy.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))))", systemImage: "gauge.with.dots.needle.bottom.50percent.badge.plus") - .font(.caption2) + .font(smallFont) } } } diff --git a/Meshtastic/Views/Settings/RouteRecorder.swift b/Meshtastic/Views/Settings/RouteRecorder.swift index d8253c97..27eaa641 100644 --- a/Meshtastic/Views/Settings/RouteRecorder.swift +++ b/Meshtastic/Views/Settings/RouteRecorder.swift @@ -30,142 +30,209 @@ struct TimerDisplayObject { @available(iOS 17.0, macOS 14.0, *) struct RouteRecorder: View { - @ObservedObject var locationsHandler = LocationsHandler.shared + @ObservedObject var locationsHandler: LocationsHandler = LocationsHandler.shared @Environment(\.managedObjectContext) var context @State private var position: MapCameraPosition = .userLocation(followsHeading: true, fallback: .automatic) - @State var isTimerRunning = false @State var isShowingDetails = false - @State var timer: Timer? @Namespace var namespace @Namespace var routerecorderscope - @State var timeElapsed: TimerDisplayObject = TimerDisplayObject() - @State var timerDisplay = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var body: some View { VStack { VStack { - VStack { - Map(position: $position, scope: routerecorderscope) { - UserAnnotation() + Map(position: $position, scope: routerecorderscope) { + UserAnnotation() // ForEach(locations, id: \.id) { location in // Marker(location.name, systemImage: location.icon, coordinate: location.location) // .tint(location.colour) // } - } } - .mapScope(routerecorderscope) - .mapControls { - MapUserLocationButton() - MapCompass() - MapScaleView() - MapPitchToggle() - } - .mapStyle(.hybrid(elevation: .realistic, showsTraffic: true)) - .transition(.slide) - .mapControlVisibility(.visible) - .safeAreaInset(edge: .bottom) { - ZStack { - VStack { - HStack(spacing: 10) { - Spacer() - if isTimerRunning { - Button { - isShowingDetails = true - isTimerRunning = false - } label: { - Image(systemName: "pause.fill") - .frame(width: 60, height: 60) - } - .buttonStyle(.bordered) - .buttonBorderShape(.circle) - .matchedGeometryEffect(id: "Pause Button", in: namespace) - } else { - Button { - isShowingDetails = true - isTimerRunning = true - timeElapsed.seconds -= 1 - } label: { - Image(systemName: "play.fill") - .frame(width: 60, height: 60) - } - .buttonStyle(.bordered) - .buttonBorderShape(.circle) - .matchedGeometryEffect(id: "Play Button", in: namespace) - } - Spacer() - } - } - .onReceive(timerDisplay) { _ in - if isTimerRunning { - timeElapsed.seconds += 1 - if timeElapsed.seconds == 60 { - timeElapsed.seconds = 0 - timeElapsed.minutes += 1 - if timeElapsed.minutes == 60 { - timeElapsed.minutes = 0 - timeElapsed.hours += 1 - } - } + } + .mapScope(routerecorderscope) + .mapControls { + MapUserLocationButton() + MapCompass() + MapScaleView() + MapPitchToggle() + } + .mapStyle(.hybrid(elevation: .realistic, showsTraffic: true)) + .transition(.slide) + .mapControlVisibility(.visible) + .safeAreaInset(edge: .bottom) { + ZStack { + VStack { + HStack(spacing: 10) { + Spacer() + + Button { + isShowingDetails = true + } label: { + Image(systemName: locationsHandler.isRecording ? "record.circle.fill" : "record.circle") + .font(.system(size: 72)) + .symbolRenderingMode(.multicolor) + .foregroundColor(.red) } + .buttonStyle(.bordered) + .foregroundColor(.red) + .buttonBorderShape(.circle) + .matchedGeometryEffect(id: "Details Button", in: namespace) + + Spacer() } } - .padding() } - .sheet(isPresented: $isShowingDetails) { - NavigationStack { - VStack { - HStack { - Text(timeElapsed.display) - .font(.largeTitle) - Text("Time Elapseed") - .font(.callout) + .padding() + } + .sheet(isPresented: $isShowingDetails) { + NavigationStack { + VStack { + if locationsHandler.isRecording { + HStack (alignment: .center) { + Image(systemName: "record.circle.fill") + .symbolRenderingMode(.multicolor) + .font(.title3) + .foregroundColor(.red) + Text("Recording route - \(locationsHandler.count) locations") + .font(.title3) } - .padding() + .padding(.top) + } else if locationsHandler.isRecordingPaused { + HStack (alignment: .center) { + + Image(systemName: "playpause") + .symbolRenderingMode(.multicolor) + .font(.title3) + .foregroundColor(.red) + Text("Route recording paused") + .font(.title3) + } + .padding(.top) + } + + + if locationsHandler.isRecording || locationsHandler.isRecordingPaused { Divider() - VStack(alignment: .leading) { - if let lastLocation = locationsHandler.locationsArray.last { - - let horizontalAccuracy = Measurement(value: lastLocation.horizontalAccuracy, unit: UnitLength.meters) - let verticalAccuracy = Measurement(value: lastLocation.verticalAccuracy, unit: UnitLength.meters) - let altitiude = Measurement(value: lastLocation.altitude, unit: UnitLength.meters) - let speed = Measurement(value: lastLocation.speed, unit: UnitSpeed.kilometersPerHour) - List { - Label("Coordinate \(String(format: "%.5f", lastLocation.coordinate.latitude)), \(String(format: "%.5f", lastLocation.coordinate.longitude))", systemImage: "mappin") - .textSelection(.enabled) - Label("Horizontal Accuracy \(horizontalAccuracy.formatted())", systemImage: "scope") - if lastLocation.verticalAccuracy > 0 { - Label("Altitude \(altitiude.formatted())", systemImage: "mountain.2") - } - Label("Vertical Accuracy \(verticalAccuracy.formatted())", systemImage: "lines.measurement.vertical") - Label("Satellites Estimate \(LocationHelper.satsInView)", systemImage: "sparkles") - Label("\(locationsHandler.isStationary ? "Moving" : "Stationary")", systemImage: locationsHandler.isStationary ? "figure.walk.motion" : "figure.stand") - if lastLocation.speedAccuracy > 0 { - Label("Speed \(speed.formatted())", systemImage: "speedometer") - } - if lastLocation.courseAccuracy > 0 { - /// Heading - let degrees = Angle.degrees(Double(lastLocation.course)) - Label { - let heading = Measurement(value: degrees.degrees, unit: UnitAngle.degrees) - /// Text("Heading: \(heading.formatted())") - Text("Heading \(String(format: "%.2f", lastLocation.course))°") - .foregroundColor(.primary) - } icon: { - Image(systemName: "location.circle") - .symbolRenderingMode(.hierarchical) - .frame(width: 35) - .rotationEffect(degrees) - } - } - } - .listStyle(.plain) + HStack { + VStack { + Text(locationsHandler.recordingStarted ?? Date(), style: .timer) + .font(.largeTitle) + .fixedSize() + Text("Time") + .font(.callout) + .fixedSize() } + .padding(.horizontal) + Divider() + VStack { + let distance = Measurement(value: locationsHandler.distanceTraveled, unit: UnitLength.meters) + Text("\(distance.formatted())") + .font(.largeTitle) + .fixedSize() + Text("Distance") + .font(.callout) + .fixedSize() + } + .padding(.horizontal) + Divider() + VStack { + let gain = Measurement(value: locationsHandler.elevationGain, unit: UnitLength.meters) + Text(gain.formatted()) + .font(.largeTitle) + Text("Elev. Gain") + .font(.callout) + } + .padding(.horizontal) + + } + .frame(maxHeight: 90) + } + Divider() + VStack(alignment: .leading) { + List { + GPSStatus(largeFont: .body, smallFont: .callout) + } + .listStyle(.plain) + HStack { + Spacer() + if !locationsHandler.isRecording && !locationsHandler.isRecordingPaused { + /// We are not recording or paused, show start recording button a new recording + Button { + locationsHandler.isRecording = true + locationsHandler.count = 0 + locationsHandler.distanceTraveled = 0.0 + locationsHandler.elevationGain = 0.0 + locationsHandler.locationsArray.removeAll() + locationsHandler.recordingStarted = Date() + } label: { + Label("start", systemImage: "start") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) + + } else if locationsHandler.isRecording { + /// We are recording show pause button + Button { + locationsHandler.isRecording = false + locationsHandler.isRecordingPaused = true + } label: { + Label("pause", systemImage: "pause") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) + } else if locationsHandler.isRecordingPaused { + /// We are recording show pause button + Button { + locationsHandler.isRecording = true + locationsHandler.isRecordingPaused = false + } label: { + Label("resume", systemImage: "playpause") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) + } + + if locationsHandler.isRecording || locationsHandler.isRecordingPaused { + + /// We are recording show pause button + Button { + locationsHandler.isRecording = false + locationsHandler.isRecordingPaused = false + locationsHandler.distanceTraveled = 0.0 + locationsHandler.elevationGain = 0.0 + locationsHandler.locationsArray.removeAll() + locationsHandler.recordingStarted = nil + } label: { + Label("finish", systemImage: "flag.checkered") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) + } + + Button(role: .cancel) { + isShowingDetails = false + } label: { + Label("close", systemImage: "xmark") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) + Spacer() } } } - .presentationDetents([.fraction(0.6)]) - .presentationDragIndicator(.visible) } + .presentationDetents([.fraction(0.65)]) + .presentationDragIndicator(.hidden) + .interactiveDismissDisabled() } } } diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index af2c8cc8..2c201a7b 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -70,14 +70,14 @@ struct Settings: View { } .tag(SettingsSidebar.routes) -// NavigationLink { -// RouteRecorder() -// } label: { -// Image(systemName: "record.circle") -// .symbolRenderingMode(.hierarchical) -// Text("route.recorder") -// } -// .tag(SettingsSidebar.routeRecorder) + NavigationLink { + RouteRecorder() + } label: { + Image(systemName: "record.circle") + .symbolRenderingMode(.hierarchical) + Text("route.recorder") + } + .tag(SettingsSidebar.routeRecorder) } let node = nodes.first(where: { $0.num == preferredNodeNum })