USB-Meter / USB Meter / Views / Chargers / ChargerStandbyPowerWizardView.swift
1 contributor
994 lines | 41.146kb
//
//  ChargerStandbyPowerWizardView.swift
//  USB Meter
//
//  Created by Codex on 13/04/2026.
//

import SwiftUI
import UniformTypeIdentifiers

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)
        .navigationBarTitleDisplayMode(.inline)
        .sheet(isPresented: $chargerLibraryVisibility) {
            ChargedDeviceLibrarySheetView(
                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
                )

                StandbyPowerDistributionCard(
                    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 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"
    }

}

// MARK: - Distribution card with resolution picker and CSV export

private struct StandbyPowerDistributionCard: View {
    let histogram: [ChargerStandbyPowerDistributionBin]
    let averagePowerWatts: Double
    let standardDeviationPowerWatts: Double
    let tint: Color
    var showExport: Bool = false

    private func resolution(for width: CGFloat) -> HistogramResolution {
        if width >= 600 { return .x4 }
        if width >= 360 { return .x2 }
        return .x1
    }

    private func displayedHistogram(width: CGFloat) -> [ChargerStandbyPowerDistributionBin] {
        let factor = HistogramResolution.x4.rawValue / resolution(for: width).rawValue
        return ChargerStandbyPowerMeasurementAnalyzer.downsample(histogram, factor: factor)
    }

    private var csvString: String {
        var lines = ["Bin,Lower Bound (W),Upper Bound (W),Count,Relative Frequency (%)"]
        for bin in histogram {
            lines.append(
                "\(bin.index + 1),"
                + String(format: "%.6f", bin.lowerBoundWatts) + ","
                + String(format: "%.6f", bin.upperBoundWatts) + ","
                + "\(bin.count),"
                + String(format: "%.4f", bin.relativeFrequency * 100)
            )
        }
        return lines.joined(separator: "\n")
    }

    var body: some View {
        MeterInfoCardView(
            title: "Value Spread",
            infoMessage: "Bars show how often each power interval appeared. The curve overlays a normal approximation around the observed mean and standard deviation.",
            tint: tint,
            trailingActions: {
                if showExport {
                    if #available(iOS 16, *) {
                        ShareLink(
                            item: DistributionCSVExport(content: csvString),
                            preview: SharePreview("distribution.csv")
                        ) {
                            Image(systemName: "square.and.arrow.up")
                                .font(.subheadline.weight(.medium))
                                .foregroundStyle(.secondary)
                        }
                    } else {
                        Button {
                            exportCSVLegacy(csvString)
                        } label: {
                            Image(systemName: "square.and.arrow.up")
                                .font(.subheadline.weight(.medium))
                                .foregroundStyle(.secondary)
                        }
                    }
                }
            }
        ) {
            GeometryReader { proxy in
                let bins = displayedHistogram(width: proxy.size.width)
                StandbyPowerHistogramView(
                    histogram: bins,
                    averagePowerWatts: averagePowerWatts,
                    standardDeviationPowerWatts: standardDeviationPowerWatts,
                    tint: tint
                )

                if let firstBin = bins.first, let lastBin = bins.last {
                    let midpointWatts = (firstBin.lowerBoundWatts + lastBin.upperBoundWatts) / 2
                    VStack {
                        Spacer()
                        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()
                    }
                }
            }
            .frame(height: 240)
        }
    }

    private func exportCSVLegacy(_ csv: String) {
        guard let windowScene = UIApplication.shared.connectedScenes
            .compactMap({ $0 as? UIWindowScene }).first,
              let rootVC = windowScene.windows.first?.rootViewController else { return }
        let activityVC = UIActivityViewController(
            activityItems: [csv],
            applicationActivities: nil
        )
        rootVC.present(activityVC, animated: true)
    }
}

@available(iOS 16, *)
struct DistributionCSVExport: Transferable {
    let content: String

    static var transferRepresentation: some TransferRepresentation {
        DataRepresentation(exportedContentType: .commaSeparatedText) { export in
            Data(export.content.utf8)
        }
        .suggestedFileName("distribution")
    }
}

// MARK: - Histogram bars + Gaussian curve

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>()
    @State private var editMode: EditMode = .inactive

    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")
                    .navigationBarTitleDisplayMode(.inline)
            }
        }
    }

    @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
                        )
                    }
                }
            }
        }
        .environment(\.editMode, $editMode)
        .navigationTitle("Saved Measurements")
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button(editMode.isEditing ? "Done" : "Select") {
                    if editMode.isEditing {
                        editMode = .inactive
                        selectedMeasurementIDs.removeAll()
                    } else {
                        editMode = .active
                    }
                }
            }
        }

        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()
        editMode = .inactive
    }
}

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")
                .navigationBarTitleDisplayMode(.inline)
                .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")
                    .navigationBarTitleDisplayMode(.inline)
            }
        }
    }

    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 {
        StandbyPowerDistributionCard(
            histogram: measurement.storedHistogram,
            averagePowerWatts: measurement.averagePowerWatts,
            standardDeviationPowerWatts: measurement.standardDeviationPowerWatts,
            tint: .orange,
            showExport: true
        )
    }

    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"
    }
}