diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 2a4d8eb4..639708cb 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ DD41582628582E9B009B0E59 /* DeviceConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD41582528582E9B009B0E59 /* DeviceConfig.swift */; }; DD415828285859C4009B0E59 /* TelemetryConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD415827285859C4009B0E59 /* TelemetryConfig.swift */; }; DD41582A28585C32009B0E59 /* RangeTestConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD41582928585C32009B0E59 /* RangeTestConfig.swift */; }; + DD41A61529AB0035003C5A37 /* NodeWeatherForecast.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD41A61429AB0035003C5A37 /* NodeWeatherForecast.swift */; }; DD457188293C7E63000C49FB /* SignalStrengthIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD457187293C7E63000C49FB /* SignalStrengthIndicator.swift */; }; DD47E3CE26F103C600029299 /* NodeList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD47E3CD26F103C600029299 /* NodeList.swift */; }; DD47E3D626F17ED900029299 /* CircleText.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD47E3D526F17ED900029299 /* CircleText.swift */; }; @@ -160,6 +161,7 @@ DD41582528582E9B009B0E59 /* DeviceConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceConfig.swift; sourceTree = ""; }; DD415827285859C4009B0E59 /* TelemetryConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryConfig.swift; sourceTree = ""; }; DD41582928585C32009B0E59 /* RangeTestConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeTestConfig.swift; sourceTree = ""; }; + DD41A61429AB0035003C5A37 /* NodeWeatherForecast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeWeatherForecast.swift; sourceTree = ""; }; DD457187293C7E63000C49FB /* SignalStrengthIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalStrengthIndicator.swift; sourceTree = ""; }; DD457BC4295D5E35004BCE4D /* MeshtasticDataModelV5.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV5.xcdatamodel; sourceTree = ""; }; DD47E3CD26F103C600029299 /* NodeList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeList.swift; sourceTree = ""; }; @@ -377,6 +379,7 @@ children = ( DD5E523E298F5A9E00D21B61 /* AirQualityIndexCompact.swift */, DDFEB3BA29900C1200EE7472 /* CurrentConditionsCompact.swift */, + DD41A61429AB0035003C5A37 /* NodeWeatherForecast.swift */, ); path = Weather; sourceTree = ""; @@ -788,6 +791,7 @@ DD5394FE276BA0EF00AD86B1 /* PositionEntityExtension.swift in Sources */, DD913639270DFF4C00D7ACF3 /* LocalNotificationManager.swift in Sources */, DD4F23CD28779A3C001D37CB /* EnvironmentMetricsLog.swift in Sources */, + DD41A61529AB0035003C5A37 /* NodeWeatherForecast.swift in Sources */, DDB6ABD628AE742000384BA1 /* BluetoothConfig.swift in Sources */, DD769E0328D18BF1001A3F05 /* DeviceMetricsLog.swift in Sources */, DDAF8C5326EB1DF10058C060 /* BLEManager.swift in Sources */, diff --git a/Meshtastic/Views/Helpers/Weather/NodeWeatherForecast.swift b/Meshtastic/Views/Helpers/Weather/NodeWeatherForecast.swift new file mode 100644 index 00000000..d5ff2d30 --- /dev/null +++ b/Meshtastic/Views/Helpers/Weather/NodeWeatherForecast.swift @@ -0,0 +1,218 @@ +// +// NodeWeatherForecast.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 2/25/23. +// + +import SwiftUI +import CoreLocation +import Charts +import WeatherKit + +struct NodeWeatherForecastView: View { + var location: CLLocation + + @State private var forecast: NodeWeatherForecast = placeholderForecast + + var body: some View { + VStack { + chart + .frame(width: 400) + } + //.frame(width: 350, height: 200) + .padding(10) + .background() + .task { + do { + let weather = try await WeatherService.shared.weather(for: location, including: .hourly).forecast + forecast = NodeWeatherForecast(entries: weather.map { + .init( + date: $0.date, + degrees: $0.temperature.converted(to: .fahrenheit).value, + isDaylight: $0.isDaylight + ) + }) + } catch { + print("Could not load weather", error.localizedDescription) + } + } + } + + var chart: some View { + Chart { + areaMarks(seriesKey: "Temperature", value: 0) + .foregroundStyle(.linearGradient(colors: [.teal, .yellow], startPoint: .bottom, endPoint: .top)) + + ForEach(forecast.nightTimeRanges, id: \.lowerBound) { range in + RectangleMark( + xStart: .value("Hour", range.lowerBound), + xEnd: .value("Hour", range.upperBound) + ) + .opacity(0.5) + .mask { + areaMarks(seriesKey: "Mask", value: range.lowerBound.timeIntervalSince1970) + } + + if range.lowerBound != forecast.entries.first!.date { + let date = range.lowerBound + RectangleMark( + x: .value("Date", date), + yStart: .value("Temperature", forecast.low - 0.5), + yEnd: .value("Temperature", forecast.temperature(at: date) + 0.5), + width: .fixed(4) + ) + .foregroundStyle(.indigo.shadow(.drop(color: .white.opacity(0.25), radius: 0, x: 1))) + .cornerRadius(2) + .annotation(position: .top, alignment: .bottom, spacing: 5) { + Image(systemName: "moon.circle.fill") + .imageScale(.large) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .indigo) + } + } + + if range.upperBound != forecast.entries.last!.date { + let date = range.upperBound + RectangleMark( + x: .value("Date", date), + yStart: .value("Temperature", forecast.low - 0.5), + yEnd: .value("Temperature", forecast.temperature(at: date) + 0.5), + width: .fixed(4) + ) + .foregroundStyle(.indigo.shadow(.drop(color: .white.opacity(0.25), radius: 0, x: -1))) + .cornerRadius(2) + .annotation(position: .top, alignment: .bottom, spacing: 5) { + Image(systemName: "sun.max.circle.fill") + .imageScale(.large) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .indigo) + } + } + } + } + .chartXAxis { + AxisMarks(values: DateBins(unit: .hour, by: 3, range: forecast.binRange).thresholds) { _ in + AxisValueLabel(format: .dateTime.hour()) + AxisTick() + AxisGridLine() + } + } + .chartYScale(domain: .automatic(includesZero: false)) + .chartYAxis { + AxisMarks(values: .automatic(minimumStride: 5, desiredCount: 6, roundLowerBound: false)) { value in + AxisValueLabel("\(value.as(Double.self)!.formatted())°F") + AxisTick() + AxisGridLine() + } + } + } + + @ChartContentBuilder + func areaMarks(seriesKey: String, value: Double) -> some ChartContent { + ForEach(forecast.entries) { entry in + AreaMark( + x: .value("Hour", entry.date), + yStart: .value("Temperature", forecast.low), + yEnd: .value("Temperature", entry.degrees), + series: .value(seriesKey, value) + ) + .interpolationMethod(.catmullRom) + } + } + + static var placeholderForecast: NodeWeatherForecast { + func entry(hourOffset: Int, degrees: Double, isDaylight: Bool) -> NodeWeatherForecast.WeatherEntry { + let startDate = Calendar.current.date(from: DateComponents(year: 2022, month: 5, day: 6, hour: 9))! + let date = Calendar.current.date(byAdding: DateComponents(hour: hourOffset), to: startDate)! + return NodeWeatherForecast.WeatherEntry(date: date, degrees: degrees, isDaylight: isDaylight) + } + + return NodeWeatherForecast(entries: [ + entry(hourOffset: 0, degrees: 63, isDaylight: true), + entry(hourOffset: 1, degrees: 68, isDaylight: true), + entry(hourOffset: 2, degrees: 72, isDaylight: true), + entry(hourOffset: 3, degrees: 77, isDaylight: true), + entry(hourOffset: 4, degrees: 80, isDaylight: true), + entry(hourOffset: 5, degrees: 82, isDaylight: true), + entry(hourOffset: 6, degrees: 83, isDaylight: true), + entry(hourOffset: 7, degrees: 83, isDaylight: true), + entry(hourOffset: 8, degrees: 81, isDaylight: true), + entry(hourOffset: 9, degrees: 79, isDaylight: true), + entry(hourOffset: 10, degrees: 75, isDaylight: true), + entry(hourOffset: 11, degrees: 70, isDaylight: true), + entry(hourOffset: 12, degrees: 66, isDaylight: false), + entry(hourOffset: 13, degrees: 64, isDaylight: false), + entry(hourOffset: 14, degrees: 63, isDaylight: false), + entry(hourOffset: 15, degrees: 61, isDaylight: false), + entry(hourOffset: 16, degrees: 60, isDaylight: false), + entry(hourOffset: 17, degrees: 59, isDaylight: false), + entry(hourOffset: 18, degrees: 57, isDaylight: false), + entry(hourOffset: 19, degrees: 56, isDaylight: false), + entry(hourOffset: 20, degrees: 55, isDaylight: false), + entry(hourOffset: 21, degrees: 55, isDaylight: true), + entry(hourOffset: 22, degrees: 56, isDaylight: true), + entry(hourOffset: 23, degrees: 59, isDaylight: true), + entry(hourOffset: 24, degrees: 62, isDaylight: true) + ]) + } +} + +struct NodeWeatherForecast { + struct WeatherEntry: Identifiable { + var id: Date { date } + var date: Date + var degrees: Double + var isDaylight: Bool + } + + var entries: [WeatherEntry] + + var low: Double { + return entries.map(\.degrees).min()! - 2 + } + + var hottestEntry: WeatherEntry { + return entries.sorted { $0.degrees > $1.degrees }.first! + } + + var nightTimeRanges: [Range] { + var currentLowerBound: Date? + var results: [Range] = [] + for entry in entries { + if entry.isDaylight, let lowerBound = currentLowerBound { + results.append(lowerBound.. { + let startDate: Date = entries.map(\.date).first(where: { + Calendar.current.component(.hour, from: $0).isMultiple(of: 3) + })! + let endDate: Date = entries.map(\.date).reversed().first(where: { + Calendar.current.component(.hour, from: $0).isMultiple(of: 3) + })! + return startDate ... endDate + } + + func temperature(at date: Date) -> Double { + entries.first(where: { $0.date == date })!.degrees + } +} + +struct NodeWeatherForecastView_Previews: PreviewProvider { + static var previews: some View { + NodeWeatherForecastView(location: CLLocation(latitude: LocationHelper.currentLocation.latitude, longitude: LocationHelper.currentLocation.longitude) ) + .aspectRatio(2, contentMode: .fit) + .padding() + .previewLayout(.sizeThatFits) + } +} diff --git a/Meshtastic/Views/Nodes/NodeDetail.swift b/Meshtastic/Views/Nodes/NodeDetail.swift index 9e967ed7..55abdea1 100644 --- a/Meshtastic/Views/Nodes/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/NodeDetail.swift @@ -18,6 +18,7 @@ struct NodeDetail: View { @State var waypointCoordinate: CLLocationCoordinate2D? @State var editingWaypoint: Int = 0 @State private var showingDetailsPopover = false + @State private var showingForecast = false @State private var showingShutdownConfirm: Bool = false @State private var showingRebootConfirm: Bool = false @State private var presentingWaypointForm = false @@ -98,6 +99,32 @@ struct NodeDetail: View { .padding(10) .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .padding(5) + #if targetEnvironment(macCatalyst) + .popover(isPresented: $showingForecast, + arrowEdge: .top) { + Text("Today's Weather Forecast") + .font(.title) + .padding() + NodeWeatherCard(location: CLLocation(latitude: LocationHelper.currentLocation.latitude, longitude: LocationHelper.currentLocation.longitude) ) + .frame(height: 250) + } + #else + .sheet(isPresented: $showingForecast) { + Text("Today's Weather Forecast") + .font(.title) + .padding() + NodeWeatherForecastView(location: CLLocation(latitude: LocationHelper.currentLocation.latitude, longitude: LocationHelper.currentLocation.longitude) ) + .frame(height: 250) + .presentationDetents([.medium]) + .presentationDragIndicator(.automatic) + } + #endif + .gesture( + LongPressGesture(minimumDuration: 0.5) + .onEnded { value in + showingForecast = true + } + ) } } }