USB-Meter / USB Meter / Views / ChargedDevices / ConsumptionMonitorView.swift
1 contributor
432 lines | 17.066kb
//
//  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)
            }
        }
    }
}