USB-Meter / USB Meter / Views / ChargedDevices / Sessions / ChargedDeviceSessionsView.swift
1 contributor
263 lines | 10.184kb
//
//  ChargedDeviceSessionsView.swift
//  USB Meter
//
//  Created by Codex on 22/04/2026.
//

import SwiftUI

struct ChargedDeviceSessionsView: View {
    @EnvironmentObject private var appData: AppData
    @State private var pendingSessionDeletion: ChargeSessionSummary?

    let chargedDeviceID: UUID

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

    private var sessions: [ChargeSessionSummary] {
        chargedDevice?.sessions.filter { !$0.status.isOpen } ?? []
    }

    var body: some View {
        Group {
            if let chargedDevice {
                ScrollView {
                    VStack(spacing: 14) {
                        if sessions.isEmpty {
                            emptyState
                        } else {
                            summaryHeader(chargedDevice)

                            ForEach(sessions, id: \.id) { session in
                                sessionCard(session, chargedDevice: chargedDevice)
                            }
                        }
                    }
                    .padding()
                }
                .background(
                    LinearGradient(
                        colors: [tint(for: chargedDevice).opacity(0.14), Color.clear],
                        startPoint: .topLeading,
                        endPoint: .bottomTrailing
                    )
                    .ignoresSafeArea()
                )
                .navigationTitle("Sessions")
            } else {
                Text("This device is no longer available.")
                    .foregroundColor(.secondary)
                    .navigationTitle("Sessions")
            }
        }
        .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()
            )
        }
    }

    private var emptyState: some View {
        VStack(spacing: 10) {
            Image(systemName: "clock")
                .font(.system(size: 34, weight: .semibold))
                .foregroundColor(.secondary)
            Text("No Closed Sessions")
                .font(.headline)
            Text("Completed and abandoned sessions will appear here after they are closed.")
                .font(.footnote)
                .foregroundColor(.secondary)
                .multilineTextAlignment(.center)
        }
        .frame(maxWidth: .infinity)
        .padding(24)
        .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 18)
    }

    private func summaryHeader(_ chargedDevice: ChargedDeviceSummary) -> some View {
        let totalEnergyWh = sessions.reduce(0) { $0 + $1.effectiveOrMeasuredEnergyWh }
        let completedCount = sessions.filter { $0.status == .completed }.count

        return MeterInfoCardView(title: chargedDevice.name, tint: tint(for: chargedDevice)) {
            MeterInfoRowView(label: "Closed Sessions", value: "\(sessions.count)")
            MeterInfoRowView(label: "Completed", value: "\(completedCount)")
            MeterInfoRowView(label: "Total Energy", value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh")
        }
    }

    private func sessionCard(_ session: ChargeSessionSummary, chargedDevice: ChargedDeviceSummary) -> some View {
        VStack(alignment: .leading, spacing: 10) {
            NavigationLink(
                destination: ChargedDeviceSessionDetailView(
                    chargedDeviceID: chargedDevice.id,
                    sessionID: session.id
                )
            ) {
                VStack(alignment: .leading, spacing: 10) {
                    HStack(alignment: .firstTextBaseline, spacing: 10) {
                        Text(session.startedAt.format())
                            .font(.subheadline.weight(.semibold))
                            .foregroundColor(.primary)

                        Text(session.status.title)
                            .font(.caption2.weight(.semibold))
                            .foregroundColor(statusTint(for: session))
                            .padding(.horizontal, 8)
                            .padding(.vertical, 4)
                            .background(
                                Capsule()
                                    .fill(statusTint(for: session).opacity(0.16))
                            )

                        Spacer()

                        Image(systemName: "chevron.right")
                            .font(.caption.weight(.semibold))
                            .foregroundColor(.secondary)
                    }

                    Text(sessionSummaryLine(session, chargedDevice: chargedDevice))
                        .font(.caption)
                        .foregroundColor(.secondary)

                    LazyVGrid(columns: metricColumns, spacing: 8) {
                        metricCell(label: "Energy", value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh", tint: .teal)
                        metricCell(label: "Duration", value: sessionDurationText(session), tint: .orange)
                        if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
                            metricCell(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W", tint: .blue)
                        }
                        if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
                            metricCell(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A", tint: .indigo)
                        }
                    }
                }
            }
            .buttonStyle(.plain)

            Divider()

            HStack {
                if !session.displayedAggregatedSamples.isEmpty {
                    Label("\(session.displayedAggregatedSamples.count) curve points", systemImage: "chart.xyaxis.line")
                        .font(.caption2)
                        .foregroundColor(.secondary)
                }

                Spacer()

                Button(role: .destructive) {
                    pendingSessionDeletion = session
                } label: {
                    Image(systemName: "trash")
                        .font(.caption.weight(.semibold))
                        .foregroundColor(.red)
                        .frame(width: 30, height: 30)
                        .background(
                            Circle()
                                .fill(Color.red.opacity(0.10))
                        )
                }
                .buttonStyle(.plain)
                .help("Delete session")
            }
        }
        .padding(14)
        .meterCard(tint: statusTint(for: session), fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
    }

    private var metricColumns: [GridItem] {
        [
            GridItem(.flexible(minimum: 92), spacing: 8),
            GridItem(.flexible(minimum: 92), spacing: 8)
        ]
    }

    private func metricCell(label: String, value: String, tint: Color) -> some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(label)
                .font(.caption2)
                .foregroundColor(.secondary)
            Text(value)
                .font(.footnote.weight(.semibold))
                .foregroundColor(.primary)
                .monospacedDigit()
                .lineLimit(1)
                .minimumScaleFactor(0.8)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(10)
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
    }

    private func sessionSummaryLine(
        _ session: ChargeSessionSummary,
        chargedDevice: ChargedDeviceSummary
    ) -> String {
        var components: [String] = []

        if let batteryDeltaPercent = session.batteryDeltaPercent {
            components.append("\(batteryDeltaPercent.format(decimalDigits: 0))%")
        }
        if let capacityEstimateWh = session.capacityEstimateWh {
            components.append("\(capacityEstimateWh.format(decimalDigits: 2)) Wh capacity")
        }
        if chargedDevice.shouldShowChargingTransport(session.chargingTransportMode) {
            components.append(session.chargingTransportMode.title)
        }
        if chargedDevice.shouldShowChargingStateMode(session.chargingStateMode) {
            components.append(session.chargingStateMode.title)
        }
        if session.isTrimmed {
            components.append("Trimmed")
        }
        components.append(session.sourceMode.title)

        return components.joined(separator: " - ")
    }

    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 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 func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
        switch chargedDevice.deviceClass {
        case .iphone:
            return .blue
        case .watch:
            return .green
        case .powerbank:
            return .orange
        case .charger:
            return .pink
        case .other:
            return .secondary
        }
    }
}