1 contributor
//
// 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<UUID>.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<String>.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)
}
}
}
}