// // ConsumptionMonitorView.swift // USB Meter // import SwiftUI import Charts // MARK: - Shared helpers (file-private) private func formattedDuration(_ duration: TimeInterval) -> String { let formatter = DateComponentsFormatter() formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : [.minute, .second] formatter.unitsStyle = .abbreviated formatter.zeroFormattingBehavior = .pad return formatter.string(from: max(duration, 0)) ?? "0m" } private func energyLabel(_ wattHours: Double) -> String { wattHours >= 1000 ? "\((wattHours / 1000).format(decimalDigits: 3)) kWh" : "\(wattHours.format(decimalDigits: 2)) Wh" } @available(iOS 16, *) private func consumptionChart(samples: [ConsumptionMonitorSample], tint: Color) -> some View { let duration = samples.last.map { $0.timestamp.timeIntervalSince(samples[0].timestamp) } ?? 0 return Chart(samples) { sample in LineMark( x: .value("Time", sample.timestamp), y: .value("W", sample.averagePowerWatts) ) .foregroundStyle(tint) .interpolationMethod(.catmullRom) } .frame(height: 140) .chartYScale(domain: .automatic(includesZero: false)) .chartXAxis { if duration > 3600 { AxisMarks(values: .stride(by: .hour)) { _ in AxisGridLine() AxisValueLabel(format: .dateTime.hour()) } } else { AxisMarks(values: .stride(by: .minute, count: 10)) { _ in AxisGridLine() AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .omitted)).minute()) } } } .chartYAxis { AxisMarks { value in AxisGridLine() AxisValueLabel { if let v = value.as(Double.self) { Text("\(v.format(decimalDigits: 1)) W") } } } } } // MARK: - Main View struct ConsumptionMonitorView: View { @EnvironmentObject private var appData: AppData @State private var selectedMeterMACAddress: String? @State private var selectedDeviceID: UUID? @State private var discardConfirmationVisibility = false let preferredMeterMACAddress: String? init(preferredMeterMACAddress: String? = nil) { self.preferredMeterMACAddress = preferredMeterMACAddress _selectedMeterMACAddress = State(initialValue: preferredMeterMACAddress) } var body: some View { ScrollView { VStack(spacing: 18) { if let session = activeSession { activeSessionCard(session) liveMetricsCard(session) } else { setupCard } savedSessionsList } .padding() } .background( LinearGradient( colors: [.purple.opacity(0.16), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .navigationTitle("Consumption Monitor") .navigationBarTitleDisplayMode(.inline) .confirmationDialog( "Stop and discard this session?", isPresented: $discardConfirmationVisibility, titleVisibility: .visible ) { Button("Discard", role: .destructive) { if let session = activeSession { _ = appData.stopConsumptionMonitor(for: session.meterMACAddress, save: false) } } Button("Cancel", role: .cancel) {} } message: { Text("The current session data will be lost and nothing will be saved.") } } // MARK: - Computed private var liveMeterSummaries: [AppData.MeterSummary] { appData.meterSummaries.filter { $0.meter != nil } } private var availableDevices: [ChargedDeviceSummary] { appData.deviceSummaries } private var activeSession: ConsumptionMonitorLiveSession? { let candidates = [selectedMeterMACAddress, preferredMeterMACAddress].compactMap { $0 } for mac in candidates { if let session = appData.consumptionMonitorSession(for: mac) { return session } } for summary in liveMeterSummaries { if let session = appData.consumptionMonitorSession(for: summary.macAddress) { return session } } return nil } private var selectedDevice: ChargedDeviceSummary? { guard let id = selectedDeviceID else { return nil } return availableDevices.first { $0.id == id } } private var selectedMeterSummary: AppData.MeterSummary? { guard let mac = selectedMeterMACAddress else { return nil } return liveMeterSummaries.first { $0.macAddress == mac } } private var savedSessions: [ConsumptionMonitorSessionSummary] { guard let id = selectedDeviceID else { return [] } return appData.chargedDeviceSummary(id: id)?.consumptionSessions.filter { !$0.isOpen } ?? [] } private var canStart: Bool { selectedDeviceID != nil && selectedMeterSummary != nil && activeSession == nil } // MARK: - Setup Card private var setupCard: some View { MeterInfoCardView(title: "New Session", tint: .purple) { VStack(alignment: .leading, spacing: 12) { if liveMeterSummaries.isEmpty { Text("Connect a live meter first to start a consumption monitor session.") .font(.footnote) .foregroundColor(.secondary) } else { Text("Device") .font(.subheadline.weight(.semibold)) if availableDevices.isEmpty { Text("No devices available. Add a device in the sidebar first.") .font(.caption) .foregroundColor(.secondary) } else { Picker("Device", selection: $selectedDeviceID) { Text("Select Device").tag(Optional.none) ForEach(availableDevices) { device in Text(device.name).tag(Optional(device.id)) } } .pickerStyle(.menu) } Text("Meter") .font(.subheadline.weight(.semibold)) Picker("Meter", selection: $selectedMeterMACAddress) { Text("Select Meter").tag(Optional.none) ForEach(liveMeterSummaries) { summary in Text(summary.displayName).tag(Optional(summary.macAddress)) } } .pickerStyle(.menu) Button("Start Session") { startSession() } .disabled(!canStart) .buttonStyle(.borderedProminent) .tint(.purple) } } if activeSession == nil, selectedDevice != nil, selectedMeterSummary == nil { Text("Select a meter to begin.") .font(.caption) .foregroundColor(.secondary) } else if activeSession == nil, selectedMeterSummary != nil, selectedDevice == nil { Text("Select the device you want to monitor.") .font(.caption) .foregroundColor(.secondary) } else if activeSession == nil, canStart { Text("Samples are recorded every 60 seconds at a rate of 60 per hour.") .font(.caption) .foregroundColor(.secondary) } } } // MARK: - Active Session Card private func activeSessionCard(_ session: ConsumptionMonitorLiveSession) -> some View { MeterInfoCardView( title: "Session Running", infoMessage: "Samples are stored every 60 seconds. The session persists across app restarts until you stop it.", tint: .purple ) { if let device = appData.chargedDeviceSummary(id: session.chargedDeviceID) { MeterInfoRowView(label: "Device", value: device.name) } if let summary = liveMeterSummaries.first(where: { $0.macAddress == session.meterMACAddress }) { MeterInfoRowView(label: "Meter", value: summary.displayName) } MeterInfoRowView(label: "Duration", value: formattedDuration(session.elapsedDuration)) MeterInfoRowView(label: "Samples", value: "\(session.committedSampleCount) × 60 s") MeterInfoRowView(label: "Total Energy", value: energyLabel(session.cumulativeEnergyWh)) HStack(spacing: 12) { Button("Save & Stop") { _ = appData.stopConsumptionMonitor(for: session.meterMACAddress, save: true) } .disabled(session.committedSampleCount == 0) Button("Discard") { discardConfirmationVisibility = true } .foregroundColor(.red) } .buttonStyle(.borderedProminent) .tint(.purple) } } // MARK: - Live Metrics Card private func liveMetricsCard(_ session: ConsumptionMonitorLiveSession) -> some View { VStack(spacing: 18) { MeterInfoCardView(title: "Live Reading", tint: .indigo) { MeterInfoRowView(label: "Power", value: "\(session.currentPowerWatts.format(decimalDigits: 3)) W") MeterInfoRowView(label: "Current", value: "\(session.currentCurrentAmps.format(decimalDigits: 3)) A") MeterInfoRowView(label: "Voltage", value: "\(session.currentVoltageVolts.format(decimalDigits: 3)) V") } if session.committedSamples.count >= 2 { liveChartCard(session.committedSamples) } if session.cumulativeEnergyWh > 0 { projectionCard( averagePowerWatts: session.cumulativeEnergyWh / max(session.elapsedDuration / 3600, 0.001), totalEnergyWh: session.cumulativeEnergyWh ) } } } @ViewBuilder private func liveChartCard(_ samples: [ConsumptionMonitorSample]) -> some View { if #available(iOS 16, *) { MeterInfoCardView(title: "Power Over Time", tint: .purple) { consumptionChart(samples: samples, tint: .purple) } } } // MARK: - Projections Card private func projectionCard(averagePowerWatts: Double, totalEnergyWh: Double) -> some View { MeterInfoCardView(title: "Consumption Projection", tint: .teal) { MeterInfoRowView(label: "Average Power", value: "\(averagePowerWatts.format(decimalDigits: 3)) W") MeterInfoRowView(label: "24 Hours", value: energyLabel(averagePowerWatts * 24)) MeterInfoRowView(label: "7 Days", value: energyLabel(averagePowerWatts * 24 * 7)) MeterInfoRowView(label: "30 Days", value: energyLabel(averagePowerWatts * 24 * 30)) MeterInfoRowView(label: "1 Year", value: energyLabel(averagePowerWatts * 24 * 365)) } } // MARK: - Saved Sessions List @ViewBuilder private var savedSessionsList: some View { if !savedSessions.isEmpty { MeterInfoCardView(title: "Saved Sessions", tint: .purple) { ForEach(savedSessions) { session in NavigationLink(destination: ConsumptionSessionDetailView(session: session)) { HStack { VStack(alignment: .leading, spacing: 2) { Text(session.startedAt, style: .date) .font(.subheadline.weight(.semibold)) Text("\(formattedDuration(session.duration)) · \(energyLabel(session.totalEnergyWh)) total") .font(.caption) .foregroundColor(.secondary) } Spacer() Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } .padding(.vertical, 4) } .buttonStyle(.plain) } } } } // MARK: - Actions private func startSession() { guard let deviceID = selectedDeviceID, let meterSummary = selectedMeterSummary, let meter = meterSummary.meter else { return } _ = appData.startConsumptionMonitor(for: deviceID, on: meter) } } // MARK: - Session Detail struct ConsumptionSessionDetailView: View { @EnvironmentObject private var appData: AppData let session: ConsumptionMonitorSessionSummary @State private var deleteConfirmationVisibility = false var body: some View { ScrollView { VStack(spacing: 18) { overviewCard if session.averagePowerWatts > 0 { projectionCard } if session.samples.count >= 2 { chartCard } statsCard } .padding() } .background( LinearGradient( colors: [.purple.opacity(0.14), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .navigationTitle("Consumption Session") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .destructiveAction) { Button(role: .destructive) { deleteConfirmationVisibility = true } label: { Image(systemName: "trash") } } } .confirmationDialog("Delete this session?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) { Button("Delete", role: .destructive) { _ = appData.deleteConsumptionSession(id: session.id, deviceID: session.chargedDeviceID) } Button("Cancel", role: .cancel) {} } } // MARK: - Cards private var overviewCard: some View { MeterInfoCardView(title: "Overview", tint: .purple) { MeterInfoRowView(label: "Started", value: session.startedAt.formatted(date: .abbreviated, time: .shortened)) if let endedAt = session.endedAt { MeterInfoRowView(label: "Ended", value: endedAt.formatted(date: .abbreviated, time: .shortened)) } MeterInfoRowView(label: "Duration", value: formattedDuration(session.duration)) MeterInfoRowView(label: "Samples", value: "\(session.sampleCount) × 60 s") MeterInfoRowView(label: "Total Energy", value: energyLabel(session.totalEnergyWh)) if let meterName = session.meterName { MeterInfoRowView(label: "Meter", value: meterName) } } } private var projectionCard: some View { MeterInfoCardView(title: "Consumption Projection", tint: .teal) { MeterInfoRowView(label: "Average Power", value: "\(session.averagePowerWatts.format(decimalDigits: 3)) W") MeterInfoRowView(label: "24 Hours", value: energyLabel(session.projectedDailyEnergyWh)) MeterInfoRowView(label: "7 Days", value: energyLabel(session.projectedWeeklyEnergyWh)) MeterInfoRowView(label: "30 Days", value: energyLabel(session.projectedMonthlyEnergyWh)) MeterInfoRowView(label: "1 Year", value: energyLabel(session.projectedYearlyEnergyWh)) } } private var statsCard: some View { MeterInfoCardView(title: "Statistics", tint: .indigo) { MeterInfoRowView(label: "Min Power", value: "\(session.minimumPowerWatts.format(decimalDigits: 3)) W") MeterInfoRowView(label: "Max Power", value: "\(session.maximumPowerWatts.format(decimalDigits: 3)) W") MeterInfoRowView(label: "Avg Current", value: "\(session.averageCurrentAmps.format(decimalDigits: 3)) A") MeterInfoRowView(label: "Avg Voltage", value: "\(session.averageVoltageVolts.format(decimalDigits: 3)) V") } } @ViewBuilder private var chartCard: some View { if #available(iOS 16, *) { MeterInfoCardView(title: "Power Over Time", tint: .purple) { consumptionChart(samples: session.samples, tint: .purple) } } } }