diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 2881b290..43ac3828 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -3241,6 +3241,9 @@ }, "Channels being added from the QR code did not save. When adding channels the names must be unique." : { + }, + "Chart" : { + }, "CHG" : { @@ -3507,6 +3510,9 @@ } } } + }, + "Config" : { + }, "config.module.paxcounter.enabled.description" : { "localizations" : { @@ -15132,6 +15138,9 @@ } } } + }, + "Metric" : { + }, "Minimum Distance" : { "localizations" : { @@ -21535,6 +21544,9 @@ }, "Supported I2C Connected sensors will be detected automatically, sensors are BMP280, BME280, BME680, MCP9808, INA219, INA260, LPS22 and SHTC3." : { + }, + "Table" : { + }, "tapback" : { "localizations" : { @@ -24065,4 +24077,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 6ea81c15..90bb4a30 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 231B3F212D087A4C0069A07D /* MetricColumnConfigurationEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F202D087A4C0069A07D /* MetricColumnConfigurationEntry.swift */; }; + 231B3F222D087A4C0069A07D /* MetricsColumnConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F1F2D087A4C0069A07D /* MetricsColumnConfiguration.swift */; }; + 231B3F252D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F242D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift */; }; + 231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */; }; 251926852C3BA97800249DF5 /* FavoriteNodeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */; }; 251926872C3BAE2200249DF5 /* NodeAlertsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */; }; 2519268A2C3BB1B200249DF5 /* ExchangePositionsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */; }; @@ -258,6 +262,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 231B3F1F2D087A4C0069A07D /* MetricsColumnConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnConfiguration.swift; sourceTree = ""; }; + 231B3F202D087A4C0069A07D /* MetricColumnConfigurationEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricColumnConfigurationEntry.swift; sourceTree = ""; }; + 231B3F242D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentMetricsColumnDefaults.swift; sourceTree = ""; }; + 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnDetail.swift; sourceTree = ""; }; 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteNodeButton.swift; sourceTree = ""; }; 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeAlertsButton.swift; sourceTree = ""; }; 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangePositionsButton.swift; sourceTree = ""; }; @@ -552,6 +560,24 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 231B3F1E2D0879BC0069A07D /* Metrics Columns */ = { + isa = PBXGroup; + children = ( + 231B3F1F2D087A4C0069A07D /* MetricsColumnConfiguration.swift */, + 231B3F202D087A4C0069A07D /* MetricColumnConfigurationEntry.swift */, + ); + path = "Metrics Columns"; + sourceTree = ""; + }; + 231B3F232D087C020069A07D /* Metrics Columns */ = { + isa = PBXGroup; + children = ( + 231B3F242D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift */, + 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */, + ); + path = "Metrics Columns"; + sourceTree = ""; + }; 251926882C3BAF2E00249DF5 /* Actions */ = { isa = PBXGroup; children = ( @@ -931,6 +957,7 @@ DDC2E18826CE24EE0042C5E4 /* Model */ = { isa = PBXGroup; children = ( + 231B3F1E2D0879BC0069A07D /* Metrics Columns */, DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */, ); path = Model; @@ -1032,6 +1059,7 @@ DDDB26402AABEF7B003AFCB7 /* Helpers */ = { isa = PBXGroup; children = ( + 231B3F232D087C020069A07D /* Metrics Columns */, DDAD49EB2AFAE82500B4425D /* Map */, DDDB26432AAC0206003AFCB7 /* NodeDetail.swift */, DDDB26452AACC0B7003AFCB7 /* NodeInfoItem.swift */, @@ -1306,6 +1334,7 @@ DD5D0A9C2931B9F200F7EA61 /* EthernetModes.swift in Sources */, 6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */, DD798B072915928D005217CD /* ChannelMessageList.swift in Sources */, + 231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */, DDC2E1A726CEB3400042C5E4 /* LocationHelper.swift in Sources */, DD77093D2AA1AFA3007A8BF0 /* ChannelTips.swift in Sources */, 6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */, @@ -1335,6 +1364,7 @@ DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */, DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */, DDC94FC129CE063B0082EA6E /* BatteryLevel.swift in Sources */, + 231B3F252D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift in Sources */, 25F5D5BE2C3F6D87008036E3 /* NavigationState.swift in Sources */, DD354FD92BD96A0B0061A25F /* IAQScale.swift in Sources */, DDDB445429F8AD1600EE2349 /* Data.swift in Sources */, @@ -1419,6 +1449,8 @@ DD3CC24C2C498D6C001BD3A2 /* BatteryCompact.swift in Sources */, BCB613812C67290800485544 /* SendWaypointIntent.swift in Sources */, DD1B8F402B35E2F10022AABC /* GPSStatus.swift in Sources */, + 231B3F212D087A4C0069A07D /* MetricColumnConfigurationEntry.swift in Sources */, + 231B3F222D087A4C0069A07D /* MetricsColumnConfiguration.swift in Sources */, DD8ED9C52898D51F00B3B0AB /* NetworkConfig.swift in Sources */, DDC3B274283F411B00AC321C /* LastHeardText.swift in Sources */, DDDE5A1029AFE69700490C6C /* MeshActivityAttributes.swift in Sources */, diff --git a/Meshtastic/Model/Metrics Columns/MetricColumnConfigurationEntry.swift b/Meshtastic/Model/Metrics Columns/MetricColumnConfigurationEntry.swift new file mode 100644 index 00000000..7ad10089 --- /dev/null +++ b/Meshtastic/Model/Metrics Columns/MetricColumnConfigurationEntry.swift @@ -0,0 +1,80 @@ +// +// SeriesConfigurationEntry.swift +// Meshtastic +// +// Created by Jake Bordens on 12/7/24. +// + +import SwiftUI +import Charts +import OSLog + +struct MetricVisualizationType: OptionSet { + let rawValue: Int + + static let chart = MetricVisualizationType(rawValue: 1 << 0) + static let table = MetricVisualizationType(rawValue: 1 << 1) + + static let all: MetricVisualizationType = [.chart, .table] +} +class MetricsColumnConfigurationEntry: ObservableObject { + let attribute: String // CoreData Attribute Name on TelemetryEntity + let availability: MetricVisualizationType // Determine where this attribute can appear + let columnName: String // Heading for wider tables + let abbreviatedColumnName: String // Heading for space-constrained tables + let minWidth: CGFloat? // Minimum grid width for this column + let maxWidth: CGFloat? // Maximum grid width for this column + let spacing: CGFloat // Recommended spacing, may be overridden + var showInTable: Bool // Should this column appear in the table + var showInChart: Bool // Should this column appear in the chart + let tableBodyClosure: (MetricsColumnConfigurationEntry, TelemetryEntity) -> AnyView // Closure to render the view + let chartBodyClosure: (MetricsColumnConfigurationEntry, TelemetryEntity) -> AnyChartContent // Closure to render the chart + + init(attribute: String, keyPath: KeyPath, + availability: MetricVisualizationType = .all, + columnName: String, abbreviatedColumnName: String, + minWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, spacing: CGFloat = 0.1, + showInTable: Bool = true, showInChart: Bool = false, + @ViewBuilder tableBody: @escaping (MetricsColumnConfigurationEntry, Value) -> TableContent, + @ChartContentBuilder chartBody: @escaping (MetricsColumnConfigurationEntry, Date, Value) -> ChartAxes) { + self.attribute = attribute + self.availability = availability + self.columnName = columnName + self.abbreviatedColumnName = abbreviatedColumnName + self.minWidth = minWidth + self.maxWidth = maxWidth + self.spacing = spacing + self.showInTable = showInTable + self.showInChart = showInChart + self.tableBodyClosure = { config, entity in AnyView(tableBody(config, entity[keyPath: keyPath])) } + self.chartBodyClosure = { config, entity in AnyChartContent(chartBody(config, entity.time!, entity[keyPath: keyPath])) } + } + + var gridItemSize: GridItem.Size { + if let minWidth, let maxWidth { + return .flexible(minimum: minWidth, maximum: maxWidth) + } + return .flexible() + } + + func tableBody(_ te: TelemetryEntity) -> AnyView { + return tableBodyClosure(self, te) + } + + func chartBody(_ te: TelemetryEntity) -> AnyChartContent { + return chartBodyClosure(self, te) + } + +} + +extension MetricsColumnConfigurationEntry: Identifiable, Hashable { + var id: String { self.attribute } + + static func == (lhs: MetricsColumnConfigurationEntry, rhs: MetricsColumnConfigurationEntry) -> Bool { + lhs.attribute == rhs.attribute + } + + func hash(into hasher: inout Hasher) { + hasher.combine(attribute) + } +} diff --git a/Meshtastic/Model/Metrics Columns/MetricsColumnConfiguration.swift b/Meshtastic/Model/Metrics Columns/MetricsColumnConfiguration.swift new file mode 100644 index 00000000..c61116d9 --- /dev/null +++ b/Meshtastic/Model/Metrics Columns/MetricsColumnConfiguration.swift @@ -0,0 +1,39 @@ +// +// SeriesConfiguration.swift +// Meshtastic +// +// Created by Jake Bordens on 12/7/24. +// +import SwiftUI + +class MetricsColumnConfiguration: ObservableObject { + + @Published var columns: [MetricsColumnConfigurationEntry] + + init(columns: [MetricsColumnConfigurationEntry]) { + self.columns = columns + } + + var activeTableColumns: [MetricsColumnConfigurationEntry] { + return columns.filter { $0.showInTable && $0.availability.contains(.table)} + } + + var activeChartColumns: [MetricsColumnConfigurationEntry] { + return columns.filter { $0.showInChart } + } + + var gridItems: [GridItem] { + var returnValues: [GridItem] = [] + let columnsInChart = self.activeTableColumns + for i in 0.. 0 { GroupBox(label: Label("\(environmentMetrics.count) Readings Total", systemImage: "chart.xyaxis.line")) { - Chart { + Chart(columnConfiguration.activeChartColumns, id: \.columnName) { series in ForEach(chartData, id: \.time) { dataPoint in - AreaMark( - x: .value("Time", dataPoint.time!), - y: .value("Temperature", dataPoint.temperature.localeTemperature()), - stacking: .unstacked - ) - .interpolationMethod(.cardinal) - .foregroundStyle( - .linearGradient( - colors: [.blue, .yellow, .orange, .red, .red], - startPoint: .bottom, endPoint: .top - ) - .opacity(0.6) - ) - .alignsMarkStylesWithPlotArea() - .accessibilityHidden(true) - LineMark( - x: .value("Time", dataPoint.time!), - y: .value("Temperature", dataPoint.temperature.localeTemperature()) - ) - .interpolationMethod(.cardinal) - .foregroundStyle( - .linearGradient( - colors: [.blue, .yellow, .orange, .red, .red], - startPoint: .bottom, endPoint: .top - ) - ) - .lineStyle(StrokeStyle(lineWidth: 4)) - .alignsMarkStylesWithPlotArea() + series.chartBody(dataPoint) } } .chartXAxis(content: { AxisMarks(position: .top) }) - .chartYScale(domain: format == .celsius ? -20...55 : 0...125) + // .chartYScale(domain: format == .celsius ? -20...55 : 0...125) .chartForegroundStyleScale([ "Temperature": .clear ]) @@ -108,46 +84,19 @@ struct EnvironmentMetricsLog: View { } } else { ScrollView { - let columns = [ - GridItem(.flexible(minimum: 30, maximum: 50), spacing: 0.1), - GridItem(.flexible(minimum: 30, maximum: 60), spacing: 0.1), - GridItem(.flexible(minimum: 30, maximum: 60), spacing: 0.1), - GridItem(.flexible(minimum: 30, maximum: 70), spacing: 0.1), - GridItem(spacing: 0) - ] - LazyVGrid(columns: columns, alignment: .leading, spacing: 1, pinnedViews: [.sectionHeaders]) { - + LazyVGrid(columns: columnConfiguration.gridItems, alignment: .leading, spacing: 1, pinnedViews: [.sectionHeaders]) { GridRow { - Text("Temp") - .font(.caption) - .fontWeight(.bold) - Text("Hum") - .font(.caption) - .fontWeight(.bold) - Text("Bar") - .font(.caption) - .fontWeight(.bold) - Text("IAQ") - .font(.caption) - .fontWeight(.bold) - Text("timestamp") - .font(.caption) - .fontWeight(.bold) + ForEach(columnConfiguration.activeTableColumns) { col in + Text(col.abbreviatedColumnName) + .font(.caption) + .fontWeight(.bold) + } } ForEach(environmentMetrics, id: \.self) { em in - GridRow { - - Text(em.temperature.formattedTemperature()) - .font(.caption) - Text("\(String(format: "%.0f", em.relativeHumidity))%") - .font(.caption) - Text("\(String(format: "%.1f", em.barometricPressure))") - .font(.caption) - IndoorAirQuality(iaq: Int(em.iaq), displayMode: .dot) - .font(.caption) - Text(em.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized) - .font(.caption) + ForEach(columnConfiguration.activeTableColumns) { col in + col.tableBody(em) + } } } } @@ -157,7 +106,18 @@ struct EnvironmentMetricsLog: View { } } HStack { - + Button { + self.isEditingColumnConfiguration = true + } label: { + Label("Config", systemImage: "gearshape") + } .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) + .padding(.leading) + .sheet(isPresented: self.$isEditingColumnConfiguration) { + MetricsColumnDetail(metricsColumnConfiguration: self.columnConfiguration) + } Button(role: .destructive) { isPresentingClearLogConfirm = true } label: { diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentMetricsColumnDefaults.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentMetricsColumnDefaults.swift new file mode 100644 index 00000000..c3fc31b1 --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentMetricsColumnDefaults.swift @@ -0,0 +1,207 @@ +// +// EnvironmentMetricsColumnDefaults.swift +// Meshtastic +// +// Created by Jake Bordens on 12/10/24. +// + +import Foundation +import SwiftUI +import Charts + +extension MetricsColumnConfiguration { + static var environmentDefaults: MetricsColumnConfiguration { MetricsColumnConfiguration(columns: [ + + // Temperature Series Configuration + MetricsColumnConfigurationEntry(attribute: "temperature", keyPath: \.temperature, + columnName: "Temperature", + abbreviatedColumnName: "Temp", + minWidth: 30, maxWidth: 50, + showInChart: true, + tableBody: { _, temp in + Text(temp.formattedTemperature()) + .font(.caption) + }, chartBody: { config, time, temperature in + AreaMark( + x: .value("Time", time), + y: .value(config.columnName, temperature.localeTemperature()), + series: .value("Metric", config.columnName), stacking: .unstacked + ) + .interpolationMethod(.cardinal) + .foregroundStyle( + .linearGradient( + colors: [.blue, .yellow, .orange, .red, .red], + startPoint: .bottom, endPoint: .top + ) + .opacity(0.6) + ) + .alignsMarkStylesWithPlotArea() + .accessibilityHidden(true) + LineMark( + x: .value("Time", time), + y: .value(config.columnName, temperature.localeTemperature()), + series: .value("Metric", config.columnName) + ) + .interpolationMethod(.cardinal) + .foregroundStyle( + .linearGradient( + colors: [.blue, .yellow, .orange, .red, .red], + startPoint: .bottom, endPoint: .top + ) + ) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + }), + + // Relative Humidity Series Configuration + MetricsColumnConfigurationEntry(attribute: "relativeHumidity", keyPath: \.relativeHumidity, + columnName: "Relative Humidity", + abbreviatedColumnName: "Hum", + minWidth: 30, maxWidth: 50, + tableBody: { _, humidity in + Text("\(String(format: "%.0f", humidity))%") + .font(.caption) + }, chartBody: { config, time, humidity in + LineMark( + x: .value("Time", time), + y: .value(config.columnName, humidity), + series: .value("Metric", config.columnName) + ) + .interpolationMethod(.cardinal) + .foregroundStyle( + .linearGradient( + colors: [.gray, .blue], + startPoint: .bottom, endPoint: .top + ) + ) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + }), + + // Barometric Pressure Series Configuration + MetricsColumnConfigurationEntry(attribute: "barometricPressure", keyPath: \.barometricPressure, + columnName: "Barometric Pressure", + abbreviatedColumnName: "Bar", + minWidth: 30, maxWidth: 60, + tableBody: { _, pressure in + Text("\(String(format: "%.1f", pressure))") + .font(.caption) + }, chartBody: { config, time, pressure in + LineMark( + x: .value("Time", time), + y: .value(config.columnName, pressure), + series: .value("Metric", config.columnName) + ) + .interpolationMethod(.cardinal) + .foregroundStyle( + .linearGradient( + colors: [.gray, .green], + startPoint: .bottom, endPoint: .top + ) + ) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + + }), + + // Indoor Air Quality Series Configuration + MetricsColumnConfigurationEntry(attribute: "iaq", keyPath: \.iaq, + columnName: "Indoor Air Quality", + abbreviatedColumnName: "IAQ", + minWidth: 30, maxWidth: 70, + tableBody: { _, iaq in + IndoorAirQuality(iaq: Int(iaq), displayMode: .dot) + .font(.caption) + }, chartBody: { config, time, iaq in + PointMark(x: .value("Time", time), + y: .value(config.columnName, 0.0)) + .symbol(Circle()) + .foregroundStyle(Iaq.getIaq(for: Int(iaq)).color) + }), + + // Wind Direction Series Configuration + MetricsColumnConfigurationEntry(attribute: "windDirection", keyPath: \.windDirection, + availability: .table, + columnName: "Wind Direction", + abbreviatedColumnName: "Dir", + minWidth: 30, maxWidth: 40, + tableBody: { _, wind in + Text(cardinalValue(from: Double(wind))) + .font(.caption) + }, chartBody: { _, _, _ in + + }), + + // Wind Speed Series Configuration + MetricsColumnConfigurationEntry(attribute: "windSpeed", keyPath: \.windSpeed, + availability: .table, + columnName: "Wind Speed", + abbreviatedColumnName: "Wind", + minWidth: 30, maxWidth: 40, + tableBody: { _, speed in + let windSpeed = Measurement(value: Double(speed), unit: UnitSpeed.kilometersPerHour) + Text(windSpeed.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0))))) + .font(.caption) + }, chartBody: { _, _, _ in + + }), + + // Combined Wind Speed and Direction Series Configuration -- For use in Chart only + MetricsColumnConfigurationEntry(attribute: "windSpeedAndDirection", keyPath: \.windSpeedAndDirection, + availability: .chart, + columnName: "Wind Speed/Direction", + abbreviatedColumnName: "Speed/Dir", + minWidth: 30, maxWidth: 40, + tableBody: { _, _ in + EmptyView() + }, chartBody: { config, time, wsad in + var wsad = (Float.random(in:0...25), Int32.random(in:0..<3)*90 ) + LineMark( + x: .value("Time", time), + y: .value(config.columnName, wsad.0), + series: .value("Metric", config.columnName) + ) + .interpolationMethod(.cardinal) + .foregroundStyle( + .linearGradient( + colors: [Color(UIColor.yellow.darker()), .yellow], + startPoint: .bottom, endPoint: .top + ) + ) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + PointMark(x: .value("Time", time), + y: .value(config.columnName, wsad.0)) + .symbol { + Image(systemName: "location.north.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(Color.white, Color.yellow) + .rotationEffect(.degrees(Double(wsad.1))) + }.foregroundStyle(.yellow) + + }), + + + // Timestamp Series Configuration -- for use in table only + MetricsColumnConfigurationEntry(attribute: "time", keyPath: \.time, + availability: .table, + columnName: "Timestamp", + abbreviatedColumnName: "Time", + tableBody: { _, time in + let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmma", options: 0, locale: Locale.current) + let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma").replacingOccurrences(of: ",", with: "") + Text(time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized) + .font(.caption) + }, chartBody: { _, _, _ in + + }) + ]) + } +} + + +extension TelemetryEntity { + var windSpeedAndDirection: (Float, Int32) { + return (self.windSpeed, self.windDirection) + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift new file mode 100644 index 00000000..2c3987a6 --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift @@ -0,0 +1,54 @@ +// +// MetricsColumnDetail.swift +// Meshtastic +// +// Created by Jake Bordens on 12/10/24. +// + +import SwiftUI + +struct MetricsColumnDetail: View { + @ObservedObject var metricsColumnConfiguration: MetricsColumnConfiguration + @State private var currentDetent = PresentationDetent.medium + + var body: some View { + List { + Section("Chart") { + ForEach(metricsColumnConfiguration.columns.filter({$0.availability.contains(.chart)}), id:\.self) { column in + HStack { + Text(column.columnName) + Spacer() + if column.showInChart { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + }.contentShape(Rectangle()) // Ensures the entire row is tappable + .onTapGesture { + metricsColumnConfiguration.objectWillChange.send() + column.showInChart.toggle() + } + } + } + Section("Table") { + ForEach(metricsColumnConfiguration.columns.filter({$0.availability.contains(.table)}), id:\.self) { column in + HStack { + Text(column.columnName) + Spacer() + if column.showInTable { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + }.contentShape(Rectangle()) // Ensures the entire row is tappable + .onTapGesture { + metricsColumnConfiguration.objectWillChange.send() + column.showInTable.toggle() + } + } + } + } + .presentationDetents([.medium, .large], selection: $currentDetent) + .presentationContentInteraction(.scrolls) + .presentationDragIndicator(.visible) + .presentationBackgroundInteraction(.enabled(upThrough: .medium)) + } +}