1 contributor
//
// ChargerStandbyPowerWizardView.swift
// USB Meter
//
// Created by Codex on 13/04/2026.
//
import SwiftUI
struct ChargerStandbyPowerWizardView: View {
@EnvironmentObject private var appData: AppData
@State private var chargerLibraryVisibility = false
@State private var discardConfirmationVisibility = false
@State private var selectedMeterMACAddress: String?
@State private var selectedChargerID: UUID?
let preferredMeterMACAddress: String?
let preferredChargerID: UUID?
let locksChargerSelection: Bool
init(
preferredMeterMACAddress: String? = nil,
preferredChargerID: UUID? = nil,
locksChargerSelection: Bool = false
) {
self.preferredMeterMACAddress = preferredMeterMACAddress
self.preferredChargerID = preferredChargerID
self.locksChargerSelection = locksChargerSelection
_selectedMeterMACAddress = State(initialValue: nil)
_selectedChargerID = State(initialValue: preferredChargerID)
}
var body: some View {
ScrollView {
VStack(spacing: 18) {
if let session = activeSession {
activeMeasurementCard(session)
liveSessionCard(session)
} else {
newMeasurementWizardCard
}
}
.padding()
}
.background(
LinearGradient(
colors: [.orange.opacity(0.16), Color.clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
)
.navigationTitle(navigationTitleText)
.sheet(isPresented: $chargerLibraryVisibility) {
ChargedDeviceLibrarySheetView(
visibility: $chargerLibraryVisibility,
meterMACAddress: selectedMeterSummary?.macAddress ?? "",
meterTint: selectedMeter?.color ?? .orange,
mode: .charger
)
.environmentObject(appData)
}
.confirmationDialog(
"Discard the current standby measurement?",
isPresented: $discardConfirmationVisibility,
titleVisibility: .visible
) {
Button("Discard", role: .destructive) {
if let activeSession {
_ = appData.finishChargerStandbyMeasurement(for: activeSession.meterMACAddress, save: false)
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("The current sample set will be removed and nothing will be saved for this charger.")
}
}
private var liveMeterSummaries: [AppData.MeterSummary] {
appData.meterSummaries.filter { $0.meter != nil }
}
private var availableChargers: [ChargedDeviceSummary] {
appData.chargerSummaries
}
private var preferredChargerMeterMACAddress: String? {
preferredChargerID.flatMap { appData.chargedDeviceSummary(id: $0)?.lastAssociatedMeterMAC }
}
private var activeSession: ChargerStandbyPowerMonitorSession? {
let candidateMACAddresses = [
selectedMeterMACAddress ?? "",
preferredMeterMACAddress ?? "",
preferredChargerMeterMACAddress ?? ""
]
.filter { $0.isEmpty == false }
for macAddress in candidateMACAddresses {
if let session = appData.chargerStandbyMeasurementSession(for: macAddress) {
return session
}
}
for meterSummary in liveMeterSummaries {
if let session = appData.chargerStandbyMeasurementSession(for: meterSummary.macAddress) {
return session
}
}
return nil
}
private var suggestedMeterSummary: AppData.MeterSummary? {
if let preferredMeterMACAddress {
return liveMeterSummaries.first(where: { $0.macAddress == preferredMeterMACAddress })
}
if let preferredChargerMeterMACAddress {
return liveMeterSummaries.first(where: { $0.macAddress == preferredChargerMeterMACAddress })
}
return liveMeterSummaries.count == 1 ? liveMeterSummaries.first : nil
}
private var selectedMeterSummary: AppData.MeterSummary? {
if let activeSession {
return liveMeterSummaries.first(where: { $0.macAddress == activeSession.meterMACAddress })
}
guard let selectedMeterMACAddress else {
return nil
}
return liveMeterSummaries.first(where: { $0.macAddress == selectedMeterMACAddress })
}
private var selectedMeter: Meter? {
selectedMeterSummary?.meter
}
private var isChargerSelectionLocked: Bool {
locksChargerSelection || preferredChargerID != nil
}
private var meterSelectionBinding: Binding<String?> {
Binding(
get: { selectedMeterMACAddress },
set: { selectedMeterMACAddress = $0 }
)
}
private var selectedCharger: ChargedDeviceSummary? {
if let activeSession {
return appData.chargedDeviceSummary(id: activeSession.chargerID)
}
guard let selectedChargerID else {
return nil
}
return appData.chargedDeviceSummary(id: selectedChargerID)
}
private var chargerSelectionBinding: Binding<UUID?> {
Binding(
get: { selectedChargerID },
set: { selectedChargerID = $0 }
)
}
private var preferredChargerSummary: ChargedDeviceSummary? {
guard let preferredChargerID else {
return nil
}
return appData.chargedDeviceSummary(id: preferredChargerID)
}
private var navigationTitleText: String {
"New Standby Consumption Measurement"
}
private var wizardCardTitle: String {
if let selectedMeterSummary {
return selectedMeterSummary.displayName
}
if let suggestedMeterSummary {
return suggestedMeterSummary.displayName
}
return "Use Meter"
}
private var newMeasurementWizardCard: some View {
MeterInfoCardView(
title: wizardCardTitle,
tint: .orange
) {
if liveMeterSummaries.isEmpty {
Text("Connect a live meter first. Standby measurement uses a live feed, so meter selection happens here in the wizard.")
.font(.footnote)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
VStack(alignment: .leading, spacing: 12) {
if isChargerSelectionLocked == false {
HStack(spacing: 8) {
Text("Charger")
.font(.subheadline.weight(.semibold))
ContextInfoButton(
title: "Charger",
message: "Choose the charger whose standby consumption you want to measure in this run."
)
}
if availableChargers.isEmpty {
Text("No charger available yet. Open the charger library to create one first.")
.font(.caption)
.foregroundColor(.secondary)
} else {
Picker("Charger", selection: chargerSelectionBinding) {
Text("Select Charger").tag(Optional<UUID>.none)
ForEach(availableChargers) { charger in
Text(charger.name).tag(Optional(charger.id))
}
}
.pickerStyle(.menu)
}
}
HStack(spacing: 8) {
Text("Use Meter")
.font(.subheadline.weight(.semibold))
ContextInfoButton(
title: "Use Meter",
message: "Choose the live meter explicitly. Standby consumption can vary with the upstream source or when the charger is connected to a computer, so re-run after setup changes."
)
}
Picker("Use Meter", selection: meterSelectionBinding) {
Text("Select Meter").tag(Optional<String>.none)
ForEach(liveMeterSummaries) { meterSummary in
Text(meterSummary.displayName).tag(Optional(meterSummary.macAddress))
}
}
.pickerStyle(.menu)
.disabled(activeSession != nil)
if activeSession == nil, let suggestedMeterSummary, selectedMeterSummary == nil {
Text("Suggested from the current context: \(suggestedMeterSummary.displayName). Select it explicitly if this is the meter you want to use.")
.font(.caption)
.foregroundColor(.secondary)
}
HStack(spacing: 12) {
if isChargerSelectionLocked == false {
Button("Manage Charger Library") {
chargerLibraryVisibility = true
}
.disabled(selectedMeter == nil)
}
Button("Start Measurement") {
startMeasurement()
}
.disabled(selectedCharger == nil || selectedMeter == nil)
}
.buttonStyle(.borderedProminent)
}
}
if selectedMeter == nil {
Text("Choose the live meter explicitly before starting. The wizard no longer auto-confirms a suggested meter.")
.font(.caption)
.foregroundColor(.secondary)
} else if activeSession == nil, selectedCharger == nil {
Text("Select the charger you want to measure, then start the run.")
.font(.caption)
.foregroundColor(.secondary)
} else if activeSession == nil, selectedMeter?.operationalState != .dataIsAvailable {
Text("The wizard can start now, but samples will only be captured while live meter data is available.")
.font(.caption)
.foregroundColor(.secondary)
} else if let activeSession {
Text(
"\(activeSession.readinessDescription) • \(formattedDuration(Date().timeIntervalSince(activeSession.startedAt))) • \(activeSession.sampleCount) samples"
)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
private func activeMeasurementCard(_ session: ChargerStandbyPowerMonitorSession) -> some View {
MeterInfoCardView(
title: "Measurement Running",
infoMessage: "The run keeps collecting samples while this meter stays live. Save when you are happy with the sample set, or discard to cancel it.",
tint: .orange
) {
MeterInfoRowView(label: "Meter", value: selectedMeterSummary?.displayName ?? session.meterMACAddress)
MeterInfoRowView(label: "Charger", value: selectedCharger?.name ?? "Selected charger")
MeterInfoRowView(label: "Status", value: session.readinessDescription)
MeterInfoRowView(label: "Samples", value: "\(session.sampleCount)")
HStack(spacing: 12) {
Button("Save Result") {
_ = appData.finishChargerStandbyMeasurement(for: session.meterMACAddress, save: true)
}
.disabled(session.hasSamples == false)
Button("Discard") {
discardConfirmationVisibility = true
}
.foregroundColor(.red)
}
.buttonStyle(.borderedProminent)
}
}
private func liveSessionCard(_ session: ChargerStandbyPowerMonitorSession) -> some View {
VStack(spacing: 18) {
if let statistics = session.statistics {
stabilityCard(
isStable: statistics.isStable,
averagePowerWatts: statistics.averagePowerWatts,
stabilityDeltaWatts: statistics.stabilityDeltaWatts,
stabilityToleranceWatts: statistics.stabilityToleranceWatts,
sampleCount: statistics.sampleCount
)
projectionCard(
averagePowerWatts: statistics.averagePowerWatts,
projectedDailyEnergyWh: statistics.projectedDailyEnergyWh,
projectedWeeklyEnergyWh: statistics.projectedWeeklyEnergyWh,
projectedMonthlyEnergyWh: statistics.projectedMonthlyEnergyWh,
projectedYearlyEnergyWh: statistics.projectedYearlyEnergyWh
)
distributionCard(
histogram: statistics.histogram,
averagePowerWatts: statistics.averagePowerWatts,
standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
tint: .orange
)
statisticsCard(
averagePowerWatts: statistics.averagePowerWatts,
medianPowerWatts: statistics.medianPowerWatts,
minimumPowerWatts: statistics.minimumPowerWatts,
maximumPowerWatts: statistics.maximumPowerWatts,
standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
coefficientOfVariation: statistics.coefficientOfVariation,
averageCurrentAmps: statistics.averageCurrentAmps,
averageVoltageVolts: statistics.averageVoltageVolts
)
} else {
MeterInfoCardView(title: "Live Stats", tint: .orange) {
Text("Waiting for the first valid power samples from the meter.")
.font(.footnote)
.foregroundColor(.secondary)
}
}
}
}
private func stabilityCard(
isStable: Bool,
averagePowerWatts: Double,
stabilityDeltaWatts: Double,
stabilityToleranceWatts: Double,
sampleCount: Int,
subtitle: String? = nil
) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(isStable ? "Enough Samples" : "Still Settling")
.font(.headline)
Text(subtitle ?? (isStable ? "The running average has stabilised." : "The wizard is still watching the average drift."))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Text(isStable ? "Ready" : "Live")
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.foregroundColor(isStable ? .green : .orange)
.meterCard(
tint: isStable ? .green : .orange,
fillOpacity: 0.10,
strokeOpacity: 0.16,
cornerRadius: 999
)
}
Text("\(averagePowerWatts.format(decimalDigits: 3)) W")
.font(.system(.largeTitle, design: .rounded).weight(.bold))
.monospacedDigit()
Text(
"Recent drift: \((stabilityDeltaWatts * 1000).format(decimalDigits: 2)) mW, tolerance \((stabilityToleranceWatts * 1000).format(decimalDigits: 2)) mW over \(sampleCount) samples."
)
.font(.footnote)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.meterCard(tint: isStable ? .green : .orange, fillOpacity: 0.18, strokeOpacity: 0.24)
}
private func projectionCard(
averagePowerWatts: Double,
projectedDailyEnergyWh: Double,
projectedWeeklyEnergyWh: Double,
projectedMonthlyEnergyWh: Double,
projectedYearlyEnergyWh: Double
) -> some View {
MeterInfoCardView(
title: "Consumption Projection",
infoMessage: "These projections extrapolate only from the measured standby average. They do not say anything about charger behaviour under load.",
tint: .teal
) {
MeterInfoRowView(label: "Average Power", value: "\(averagePowerWatts.format(decimalDigits: 3)) W")
MeterInfoRowView(label: "24 Hours", value: standbyEnergyLabel(projectedDailyEnergyWh))
MeterInfoRowView(label: "7 Days", value: standbyEnergyLabel(projectedWeeklyEnergyWh))
MeterInfoRowView(label: "30 Days", value: standbyEnergyLabel(projectedMonthlyEnergyWh))
MeterInfoRowView(label: "1 Year", value: standbyEnergyLabel(projectedYearlyEnergyWh))
}
}
private func distributionCard(
histogram: [ChargerStandbyPowerDistributionBin],
averagePowerWatts: Double,
standardDeviationPowerWatts: Double,
tint: Color
) -> some View {
MeterInfoCardView(
title: "Distribution",
infoMessage: "Bars show how often each power interval appeared. The curve overlays a normal approximation around the observed mean and deviation.",
tint: tint
) {
StandbyPowerHistogramView(
histogram: histogram,
averagePowerWatts: averagePowerWatts,
standardDeviationPowerWatts: standardDeviationPowerWatts,
tint: tint
)
.frame(height: 220)
if let firstBin = histogram.first, let lastBin = histogram.last {
let midpointWatts = (firstBin.lowerBoundWatts + lastBin.upperBoundWatts) / 2
HStack {
Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
Spacer()
Text("\(midpointWatts.format(decimalDigits: 3)) W")
Spacer()
Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
}
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
}
}
}
private func statisticsCard(
averagePowerWatts: Double,
medianPowerWatts: Double,
minimumPowerWatts: Double,
maximumPowerWatts: Double,
standardDeviationPowerWatts: Double,
coefficientOfVariation: Double,
averageCurrentAmps: Double,
averageVoltageVolts: Double
) -> some View {
MeterInfoCardView(title: "Interesting Stats", tint: .indigo) {
MeterInfoRowView(label: "Median", value: "\(medianPowerWatts.format(decimalDigits: 3)) W")
MeterInfoRowView(label: "Minimum", value: "\(minimumPowerWatts.format(decimalDigits: 3)) W")
MeterInfoRowView(label: "Maximum", value: "\(maximumPowerWatts.format(decimalDigits: 3)) W")
MeterInfoRowView(label: "Spread σ", value: "\(standardDeviationPowerWatts.format(decimalDigits: 4)) W")
MeterInfoRowView(label: "Variation", value: "\(Int((coefficientOfVariation * 100).rounded()))%")
MeterInfoRowView(label: "Mean Current", value: "\(averageCurrentAmps.format(decimalDigits: 3)) A")
MeterInfoRowView(label: "Mean Voltage", value: "\(averageVoltageVolts.format(decimalDigits: 3)) V")
MeterInfoRowView(label: "Power Density", value: "\(averagePowerWatts.format(decimalDigits: 3)) W steady")
}
}
private func startMeasurement() {
guard let selectedCharger, let selectedMeter else {
return
}
_ = appData.startChargerStandbyMeasurement(for: selectedCharger.id, on: selectedMeter)
}
private func standbyEnergyLabel(_ wattHours: Double) -> String {
if wattHours >= 1000 {
return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
}
return "\(wattHours.format(decimalDigits: 2)) Wh"
}
private func formattedDuration(_ duration: TimeInterval) -> String {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
formatter.unitsStyle = .abbreviated
formatter.zeroFormattingBehavior = .pad
return formatter.string(from: max(duration, 0)) ?? "0s"
}
}
private struct StandbyPowerHistogramView: View {
let histogram: [ChargerStandbyPowerDistributionBin]
let averagePowerWatts: Double
let standardDeviationPowerWatts: Double
let tint: Color
var body: some View {
GeometryReader { proxy in
let maxCount = max(Double(histogram.map(\.count).max() ?? 1), 1)
ZStack {
HStack(alignment: .bottom, spacing: 6) {
ForEach(histogram) { bin in
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(tint.opacity(0.24))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(tint.opacity(0.22), lineWidth: 1)
)
.frame(height: max(10, (Double(bin.count) / maxCount) * proxy.size.height))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
gaussianCurve(in: proxy.size)
.stroke(tint, style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round))
meanMarker(in: proxy.size)
.stroke(tint.opacity(0.9), style: StrokeStyle(lineWidth: 1.5, dash: [6, 4]))
}
}
}
private func gaussianCurve(in size: CGSize) -> Path {
guard histogram.count > 1,
standardDeviationPowerWatts > 0,
let firstBin = histogram.first,
let lastBin = histogram.last else {
return Path()
}
let minimum = firstBin.lowerBoundWatts
let maximum = lastBin.upperBoundWatts
let span = max(maximum - minimum, 0.000_001)
let sampleCount = 48
let peakDensity = 1 / (standardDeviationPowerWatts * sqrt(2 * .pi))
return Path { path in
for index in 0...sampleCount {
let progress = Double(index) / Double(sampleCount)
let value = minimum + (span * progress)
let zScore = (value - averagePowerWatts) / standardDeviationPowerWatts
let density = exp(-0.5 * zScore * zScore) / (standardDeviationPowerWatts * sqrt(2 * .pi))
let normalizedHeight = density / peakDensity
let x = progress * size.width
let y = size.height - (normalizedHeight * (Double(size.height) * 0.92))
let point = CGPoint(x: x, y: y)
if index == 0 {
path.move(to: point)
} else {
path.addLine(to: point)
}
}
}
}
private func meanMarker(in size: CGSize) -> Path {
guard let firstBin = histogram.first, let lastBin = histogram.last else {
return Path()
}
let minimum = firstBin.lowerBoundWatts
let maximum = lastBin.upperBoundWatts
let span = max(maximum - minimum, 0.000_001)
let normalizedX = min(max((averagePowerWatts - minimum) / span, 0), 1)
let x = normalizedX * size.width
return Path { path in
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: size.height))
}
}
}
struct ChargerStandbyPowerMeasurementsView: View {
@EnvironmentObject private var appData: AppData
@State private var selectedMeasurementIDs = Set<UUID>()
let chargerID: UUID
var body: some View {
Group {
if let charger = appData.chargedDeviceSummary(id: chargerID) {
measurementsList(for: charger)
} else {
Text("This charger is no longer available.")
.foregroundColor(.secondary)
.navigationTitle("Saved Measurements")
}
}
}
@ViewBuilder
private func measurementsList(for charger: ChargedDeviceSummary) -> some View {
let content = List(selection: $selectedMeasurementIDs) {
if charger.standbyPowerMeasurements.isEmpty {
Text("No standby measurements saved yet.")
.foregroundColor(.secondary)
} else {
ForEach(charger.standbyPowerMeasurements) { measurement in
NavigationLink(
destination: ChargerStandbyPowerMeasurementDetailView(
chargerID: charger.id,
measurementID: measurement.id
)
) {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(measurement.endedAt.format())
.font(.subheadline.weight(.semibold))
Spacer()
Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
.font(.subheadline.weight(.bold))
.monospacedDigit()
}
Text(
"\(formattedDuration(measurement.duration)) • \(measurement.sampleCount) samples • \(standbyEnergyLabel(measurement.projectedYearlyEnergyWh)) / year"
)
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.tag(measurement.id)
}
.onDelete { offsets in
let measurements = charger.standbyPowerMeasurements
for index in offsets {
guard measurements.indices.contains(index) else { continue }
let measurement = measurements[index]
_ = appData.deleteChargerStandbyMeasurement(
id: measurement.id,
chargerID: charger.id
)
}
}
}
}
.navigationTitle("Saved Measurements")
.toolbar {
ToolbarItem(placement: .primaryAction) {
EditButton()
}
}
if selectedMeasurementIDs.isEmpty {
content
} else {
content.toolbar {
ToolbarItem(placement: .destructiveAction) {
Button(role: .destructive) {
deleteMeasurements(
ids: selectedMeasurementIDs,
for: charger.id
)
} label: {
Image(systemName: "trash")
}
}
}
}
}
private func standbyEnergyLabel(_ wattHours: Double) -> String {
if wattHours >= 1000 {
return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
}
return "\(wattHours.format(decimalDigits: 2)) Wh"
}
private func formattedDuration(_ duration: TimeInterval) -> String {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
formatter.unitsStyle = .abbreviated
formatter.zeroFormattingBehavior = .pad
return formatter.string(from: max(duration, 0)) ?? "0s"
}
private func deleteMeasurements(ids: Set<UUID>, for chargerID: UUID) {
for id in ids {
_ = appData.deleteChargerStandbyMeasurement(id: id, chargerID: chargerID)
}
selectedMeasurementIDs.removeAll()
}
}
struct ChargerStandbyPowerMeasurementDetailView: View {
@EnvironmentObject private var appData: AppData
@Environment(\.dismiss) private var dismiss
@State private var deleteConfirmationVisibility = false
let chargerID: UUID
let measurementID: UUID
var body: some View {
Group {
if let charger = appData.chargedDeviceSummary(id: chargerID),
let measurement = charger.standbyPowerMeasurements.first(where: { $0.id == measurementID }) {
ScrollView {
VStack(spacing: 18) {
MeterInfoCardView(title: charger.name, tint: .orange) {
MeterInfoRowView(label: "Saved", value: measurement.endedAt.format())
MeterInfoRowView(label: "Average", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
MeterInfoRowView(label: "Samples", value: "\(measurement.sampleCount)")
MeterInfoRowView(label: "Duration", value: formattedDuration(measurement.duration))
}
ChargerStandbyPowerMeasurementSnapshotView(measurement: measurement)
}
.padding()
}
.background(
LinearGradient(
colors: [.orange.opacity(0.16), Color.clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
)
.navigationTitle("Measurement")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(role: .destructive) {
deleteConfirmationVisibility = true
} label: {
Label("Delete Measurement", systemImage: "trash")
}
}
}
.confirmationDialog(
"Delete this measurement?",
isPresented: $deleteConfirmationVisibility,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
let didDelete = appData.deleteChargerStandbyMeasurement(
id: measurement.id,
chargerID: charger.id
)
if didDelete {
dismiss()
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This removes the saved standby measurement from the charger history and iCloud sync.")
}
} else {
Text("This measurement is no longer available.")
.foregroundColor(.secondary)
.navigationTitle("Measurement")
}
}
}
private func formattedDuration(_ duration: TimeInterval) -> String {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
formatter.unitsStyle = .abbreviated
formatter.zeroFormattingBehavior = .pad
return formatter.string(from: max(duration, 0)) ?? "0s"
}
}
private struct ChargerStandbyPowerMeasurementSnapshotView: View {
let measurement: ChargerStandbyPowerMeasurementSummary
var body: some View {
VStack(spacing: 18) {
stabilityCard
projectionCard
distributionCard
statisticsCard
}
}
private var stabilityCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(measurement.isStable ? "Enough Samples" : "Still Settling")
.font(.headline)
Text("Saved \(measurement.endedAt.format())")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Text(measurement.isStable ? "Ready" : "Live")
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.foregroundColor(measurement.isStable ? .green : .orange)
.meterCard(
tint: measurement.isStable ? .green : .orange,
fillOpacity: 0.10,
strokeOpacity: 0.16,
cornerRadius: 999
)
}
Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
.font(.system(.largeTitle, design: .rounded).weight(.bold))
.monospacedDigit()
Text(
"Recent drift: \((measurement.stabilityDeltaWatts * 1000).format(decimalDigits: 2)) mW, tolerance \((measurement.stabilityToleranceWatts * 1000).format(decimalDigits: 2)) mW over \(measurement.sampleCount) samples."
)
.font(.footnote)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.meterCard(
tint: measurement.isStable ? .green : .orange,
fillOpacity: 0.18,
strokeOpacity: 0.24
)
}
private var projectionCard: some View {
MeterInfoCardView(
title: "Consumption Projection",
infoMessage: "These projections extrapolate only from the measured standby average. They do not say anything about charger behaviour under load.",
tint: .teal
) {
MeterInfoRowView(label: "Average Power", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
MeterInfoRowView(label: "24 Hours", value: standbyEnergyLabel(measurement.projectedDailyEnergyWh))
MeterInfoRowView(label: "7 Days", value: standbyEnergyLabel(measurement.projectedWeeklyEnergyWh))
MeterInfoRowView(label: "30 Days", value: standbyEnergyLabel(measurement.projectedMonthlyEnergyWh))
MeterInfoRowView(label: "1 Year", value: standbyEnergyLabel(measurement.projectedYearlyEnergyWh))
}
}
private var distributionCard: some View {
MeterInfoCardView(
title: "Distribution",
infoMessage: "Bars show how often each power interval appeared. The curve overlays a normal approximation around the observed mean and deviation.",
tint: .orange
) {
StandbyPowerHistogramView(
histogram: measurement.histogram,
averagePowerWatts: measurement.averagePowerWatts,
standardDeviationPowerWatts: measurement.standardDeviationPowerWatts,
tint: .orange
)
.frame(height: 220)
if let firstBin = measurement.histogram.first, let lastBin = measurement.histogram.last {
let midpointWatts = (firstBin.lowerBoundWatts + lastBin.upperBoundWatts) / 2
HStack {
Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
Spacer()
Text("\(midpointWatts.format(decimalDigits: 3)) W")
Spacer()
Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
}
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
}
}
}
private var statisticsCard: some View {
MeterInfoCardView(title: "Interesting Stats", tint: .indigo) {
MeterInfoRowView(label: "Median", value: "\(measurement.medianPowerWatts.format(decimalDigits: 3)) W")
MeterInfoRowView(label: "Minimum", value: "\(measurement.minimumPowerWatts.format(decimalDigits: 3)) W")
MeterInfoRowView(label: "Maximum", value: "\(measurement.maximumPowerWatts.format(decimalDigits: 3)) W")
MeterInfoRowView(label: "Spread σ", value: "\(measurement.standardDeviationPowerWatts.format(decimalDigits: 4)) W")
MeterInfoRowView(label: "Variation", value: "\(Int((measurement.coefficientOfVariation * 100).rounded()))%")
MeterInfoRowView(label: "Mean Current", value: "\(measurement.averageCurrentAmps.format(decimalDigits: 3)) A")
MeterInfoRowView(label: "Mean Voltage", value: "\(measurement.averageVoltageVolts.format(decimalDigits: 3)) V")
MeterInfoRowView(label: "Power Density", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W steady")
}
}
private func standbyEnergyLabel(_ wattHours: Double) -> String {
if wattHours >= 1000 {
return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
}
return "\(wattHours.format(decimalDigits: 2)) Wh"
}
}