USB-Meter / USB Meter / Views / ChargedDevices / Sessions / ChargedDeviceSessionDetailView.swift
1 contributor
427 lines | 17.69kb
//
//  ChargedDeviceSessionDetailView.swift
//  USB Meter
//
//  Created by Codex on 22/04/2026.
//

import SwiftUI

struct ChargedDeviceSessionDetailView: View {
    @EnvironmentObject private var appData: AppData
    @State private var pendingSessionDeletion: ChargeSessionSummary?
    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?

    let chargedDeviceID: UUID
    let sessionID: UUID

    private var chargedDevice: ChargedDeviceSummary? {
        appData.chargedDeviceSummary(id: chargedDeviceID)
    }

    private var session: ChargeSessionSummary? {
        chargedDevice?.sessions.first(where: { $0.id == sessionID })
    }

    var body: some View {
        Group {
            if let chargedDevice, let session {
                ScrollView {
                    VStack(spacing: 16) {
                        overviewCard(session, chargedDevice: chargedDevice)
                        energyCard(session, chargedDevice: chargedDevice)
                        observedMetricsCard(session, chargedDevice: chargedDevice)
                        batteryCard(session)

                        if !session.displayedAggregatedSamples.isEmpty {
                            storedCurveCard(session)
                        }

                        managementCard(session)
                    }
                    .padding()
                }
                .background(
                    LinearGradient(
                        colors: [statusTint(for: session).opacity(0.14), Color.clear],
                        startPoint: .topLeading,
                        endPoint: .bottomTrailing
                    )
                    .ignoresSafeArea()
                )
                .navigationTitle("Session Details")
            } else {
                Text("This session is no longer available.")
                    .foregroundColor(.secondary)
                    .navigationTitle("Session")
            }
        }
        .alert(item: $pendingSessionDeletion) { session in
            Alert(
                title: Text("Delete Session?"),
                message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
                primaryButton: .destructive(Text("Delete")) {
                    _ = appData.deleteChargeSession(sessionID: session.id)
                },
                secondaryButton: .cancel()
            )
        }
        .alert(item: $pendingCheckpointDeletion) { checkpoint in
            Alert(
                title: Text("Delete Battery Checkpoint"),
                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
                primaryButton: .destructive(Text("Delete")) {
                    _ = appData.deleteBatteryCheckpoint(
                        checkpointID: checkpoint.id,
                        for: checkpoint.sessionID
                    )
                },
                secondaryButton: .cancel()
            )
        }
    }

    private func overviewCard(
        _ session: ChargeSessionSummary,
        chargedDevice: ChargedDeviceSummary
    ) -> some View {
        MeterInfoCardView(title: "Overview", tint: statusTint(for: session)) {
            MeterInfoRowView(label: "Device", value: chargedDevice.name)
            MeterInfoRowView(label: "Status", value: session.status.title)
            MeterInfoRowView(label: "Started", value: session.startedAt.format())
            if let endedAt = session.endedAt {
                MeterInfoRowView(label: "Ended", value: endedAt.format())
            }
            MeterInfoRowView(label: "Duration", value: sessionDurationText(session))
            MeterInfoRowView(label: "Charging Type", value: session.chargingTransportMode.title)
            MeterInfoRowView(label: "Charging Mode", value: session.chargingStateMode.title)
            MeterInfoRowView(label: "Source", value: session.sourceMode.title)
            MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: session))
            if session.isTrimmed {
                MeterInfoRowView(label: "Trim Start", value: session.effectiveTrimStart.format())
                MeterInfoRowView(label: "Trim End", value: session.effectiveTrimEnd.format())
            }
            if let meterName = session.meterName {
                MeterInfoRowView(label: "Meter", value: meterName)
            } else if let meterMACAddress = session.meterMACAddress {
                MeterInfoRowView(label: "Meter", value: meterMACAddress)
            }
            if let meterModel = session.meterModel {
                MeterInfoRowView(label: "Meter Model", value: meterModel)
            }
        }
    }

    private func energyCard(
        _ session: ChargeSessionSummary,
        chargedDevice: ChargedDeviceSummary
    ) -> some View {
        MeterInfoCardView(title: "Energy", tint: .teal) {
            MeterInfoRowView(label: "Battery Energy", value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh")
            MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh")
            MeterInfoRowView(label: "Measured Charge", value: "\(session.measuredChargeAh.format(decimalDigits: 3)) Ah")
            if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
               abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
                MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh")
            }
            if let capacityEstimateWh = session.capacityEstimateWh {
                MeterInfoRowView(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh")
            }
            if let chargerID = session.chargerID,
               let charger = appData.chargedDeviceSummary(id: chargerID) {
                MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name)
            }
            if let wirelessSessionHint = wirelessSessionHint(for: session) {
                Text(wirelessSessionHint)
                    .font(.caption2)
                    .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
            }
        }
    }

    private func observedMetricsCard(
        _ session: ChargeSessionSummary,
        chargedDevice: ChargedDeviceSummary
    ) -> some View {
        MeterInfoCardView(title: "Observed Metrics", tint: .blue) {
            if let minimumObservedCurrentAmps = session.minimumObservedCurrentAmps {
                MeterInfoRowView(label: "Min Current", value: "\(minimumObservedCurrentAmps.format(decimalDigits: 2)) A")
            }
            if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
                MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A")
            }
            if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
                MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W")
            }
            if let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
                MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V")
            }
            if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
                MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V")
            }
            if let completionCurrentAmps = session.completionCurrentAmps {
                MeterInfoRowView(label: "Completion Current", value: "\(completionCurrentAmps.format(decimalDigits: 2)) A")
            }
            if session.selectedDataGroup != nil {
                MeterInfoRowView(label: "Data Group", value: "\(session.selectedDataGroup ?? 0)")
            }
        }
    }

    private func batteryCard(_ session: ChargeSessionSummary) -> some View {
        MeterInfoCardView(title: "Battery", tint: .orange) {
            if let startBatteryPercent = session.startBatteryPercent {
                MeterInfoRowView(label: "Start Battery", value: "\(startBatteryPercent.format(decimalDigits: 0))%")
            }
            if let endBatteryPercent = session.endBatteryPercent {
                MeterInfoRowView(label: "End Battery", value: "\(endBatteryPercent.format(decimalDigits: 0))%")
            }
            if let batteryDeltaPercent = session.batteryDeltaPercent {
                MeterInfoRowView(label: "Battery Delta", value: "\(batteryDeltaPercent.format(decimalDigits: 0))%")
            }

            BatteryCheckpointSectionView(
                sessionID: session.id,
                checkpoints: session.checkpoints,
                message: "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.",
                canAddCheckpoint: false,
                requirementMessage: nil,
                effectiveEnergyWhOverride: nil,
                measuredChargeAhOverride: nil,
                onDelete: { checkpoint in
                    pendingCheckpointDeletion = checkpoint
                }
            )
        }
    }

    private func managementCard(_ session: ChargeSessionSummary) -> some View {
        MeterInfoCardView(title: "Administration", tint: .red) {
            Button(role: .destructive) {
                pendingSessionDeletion = session
            } label: {
                Label("Delete Session", systemImage: "trash")
                    .font(.subheadline.weight(.semibold))
                    .frame(maxWidth: .infinity)
                    .padding(.vertical, 10)
                    .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14)
            }
            .buttonStyle(.plain)
        }
    }

    private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
        let displayedSamples = session.displayedAggregatedSamples
        let currentSeries = storedSeriesSnapshot(
            from: displayedSamples,
            minimumYSpan: 0.15
        ) { $0.averageCurrentAmps }
        let energySeries = storedSeriesSnapshot(
            from: displayedSamples,
            minimumYSpan: 0.2
        ) { $0.measuredEnergyWh }

        return VStack(alignment: .leading, spacing: 14) {
            HStack(alignment: .firstTextBaseline) {
                VStack(alignment: .leading, spacing: 4) {
                    Text("Session Curve")
                        .font(.headline)
                    Text(session.isTrimmed ? "Showing the saved trim window." : "Persisted aggregate samples for this session.")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }

                Spacer()

                Text("\(displayedSamples.count) points")
                    .font(.caption.weight(.semibold))
                    .foregroundColor(.secondary)
            }

            if let currentSeries {
                storedSeriesChart(
                    title: "Current",
                    unit: "A",
                    strokeColor: .blue,
                    snapshot: currentSeries
                )
            }

            if let energySeries {
                storedSeriesChart(
                    title: "Energy",
                    unit: "Wh",
                    strokeColor: .teal,
                    areaChart: true,
                    snapshot: energySeries
                )
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(18)
        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
    }

    private func storedSeriesSnapshot(
        from samples: [ChargeSessionSampleSummary],
        minimumYSpan: Double,
        value: (ChargeSessionSampleSummary) -> Double
    ) -> StoredSessionSeriesSnapshot? {
        let sortedSamples = samples.sorted { lhs, rhs in
            if lhs.bucketIndex != rhs.bucketIndex {
                return lhs.bucketIndex < rhs.bucketIndex
            }
            return lhs.timestamp < rhs.timestamp
        }

        guard
            let firstSample = sortedSamples.first,
            let lastSample = sortedSamples.last
        else {
            return nil
        }

        let points = sortedSamples.enumerated().map { index, sample in
            Measurements.Measurement.Point(
                id: index,
                timestamp: sample.timestamp,
                value: value(sample),
                kind: .sample
            )
        }

        let minimumValue = points.map(\.value).min() ?? 0
        let maximumValue = points.map(\.value).max() ?? minimumValue
        let context = ChartContext()
        context.setBounds(
            xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970),
            xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)),
            yMin: CGFloat(minimumValue),
            yMax: CGFloat(maximumValue)
        )
        context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan)))

        return StoredSessionSeriesSnapshot(
            points: points,
            context: context,
            minimumValue: minimumValue,
            maximumValue: maximumValue
        )
    }

    private func storedSeriesChart(
        title: String,
        unit: String,
        strokeColor: Color,
        areaChart: Bool = false,
        snapshot: StoredSessionSeriesSnapshot
    ) -> some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack(alignment: .firstTextBaseline) {
                Text(title)
                    .font(.subheadline.weight(.semibold))
                Spacer()
                Text(
                    "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) - \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)"
                )
                .font(.caption2)
                .foregroundColor(.secondary)
            }

            TimeSeriesChart(
                points: snapshot.points,
                context: snapshot.context,
                areaChart: areaChart,
                strokeColor: strokeColor
            )
            .frame(height: 118)
            .padding(.horizontal, 6)
            .padding(.vertical, 8)
            .background(
                RoundedRectangle(cornerRadius: 16)
                    .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06))
            )

            HStack {
                Text(snapshot.startLabel)
                Spacer()
                Text(snapshot.endLabel)
            }
            .font(.caption2)
            .foregroundColor(.secondary)
        }
    }

    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
        let formatter = DateComponentsFormatter()
        let effectiveDuration = max(session.effectiveDuration, 0)
        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
        formatter.unitsStyle = .abbreviated
        formatter.zeroFormattingBehavior = .dropAll
        return formatter.string(from: effectiveDuration) ?? "0m"
    }

    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
        if session.autoStopEnabled == false {
            return "Manual"
        }
        if session.stopThresholdAmps > 0 {
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
        }
        return "Learning"
    }

    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
        guard session.chargingTransportMode == .wireless else {
            return nil
        }

        var components: [String] = []
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
        }
        if session.usesEstimatedWirelessEfficiency {
            components.append("Estimated from wired baseline and checkpoints")
        }
        if session.shouldWarnAboutLowWirelessEfficiency {
            components.append("Low wireless efficiency, so capacity confidence is reduced")
        }

        return components.isEmpty ? nil : components.joined(separator: " - ")
    }

    private func statusTint(for session: ChargeSessionSummary) -> Color {
        switch session.status {
        case .active:
            return .green
        case .paused:
            return .orange
        case .completed:
            return .teal
        case .abandoned:
            return .secondary
        }
    }
}

private struct StoredSessionSeriesSnapshot {
    let points: [Measurements.Measurement.Point]
    let context: ChartContext
    let minimumValue: Double
    let maximumValue: Double

    var lastValue: Double {
        points.last?.value ?? 0
    }

    var startLabel: String {
        guard let firstTimestamp = points.first?.timestamp else { return "" }
        return firstTimestamp.formatted(date: .omitted, time: .shortened)
    }

    var endLabel: String {
        guard let lastTimestamp = points.last?.timestamp else { return "" }
        return lastTimestamp.formatted(date: .omitted, time: .shortened)
    }
}