USB-Meter / USB Meter / Views / ChargedDevices / Sessions / ChargedDeviceSessionsView.swift
1 contributor
412 lines | 15.749kb
//
//  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 } ?? []
    }

    /// Maps session ID → capacity delta vs the closest preceding session that has an estimate.
    private var capacityDeltas: [UUID: Double] {
        let sorted = sessions.sorted { $0.startedAt < $1.startedAt }
        var result: [UUID: Double] = [:]
        var previousCapacity: Double? = nil
        for session in sorted {
            if let current = session.capacityEstimateWh {
                if let prev = previousCapacity {
                    result[session.id] = current - prev
                }
                previousCapacity = current
            }
        }
        return result
    }

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

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

    // MARK: - Session Card

    private func sessionCard(
        _ session: ChargeSessionSummary,
        chargedDevice: ChargedDeviceSummary,
        capacityDelta: Double?
    ) -> some View {
        let sessionTint = statusTint(for: session)

        return VStack(alignment: .leading, spacing: 10) {
            NavigationLink(
                destination: ChargeSessionDetailView(
                    chargedDeviceID: chargedDevice.id,
                    sessionID: session.id
                )
            ) {
                VStack(alignment: .leading, spacing: 10) {
                    // Header: date + status badge
                    HStack(alignment: .firstTextBaseline, spacing: 10) {
                        Text(session.startedAt.format())
                            .font(.subheadline.weight(.semibold))
                            .foregroundColor(.primary)

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

                        if session.wasConflictHealed {
                            Image(systemName: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90")
                                .font(.caption2.weight(.semibold))
                                .foregroundColor(.orange)
                                .help("This session was automatically closed because a newer session was started on another device while offline.")
                        }

                        Spacer()

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

                    // Primary metrics: Energy + Duration
                    HStack(spacing: 8) {
                        primaryMetricCell(
                            label: "Energy",
                            value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh",
                            tint: .teal
                        )
                        primaryMetricCell(
                            label: "Duration",
                            value: sessionDurationText(session),
                            tint: .orange
                        )
                    }

                    // Charge bar (if start/end battery % known)
                    if let chargeRange = chargeBarRange(for: session) {
                        chargeBarView(range: chargeRange, tint: sessionTint)
                    }

                    // Capacity estimate + battery delta chips
                    let chips = chipContent(session: session, capacityDelta: capacityDelta)
                    if !chips.isEmpty {
                        chipsRow(chips)
                    }

                    // Secondary info line
                    let secondary = secondaryInfoLine(session, chargedDevice: chargedDevice)
                    if !secondary.isEmpty {
                        Text(secondary)
                            .font(.caption)
                            .foregroundColor(.secondary)
                    }
                }
            }
            .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: sessionTint, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
    }

    // MARK: - Primary metric cell

    private func primaryMetricCell(label: String, value: String, tint: Color) -> some View {
        VStack(alignment: .leading, spacing: 3) {
            Text(label)
                .font(.caption2)
                .foregroundColor(.secondary)
            Text(value)
                .font(.subheadline.weight(.bold))
                .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)
    }

    // MARK: - Charge bar

    /// Returns (startPercent, endPercent) if we have enough data to render a charge bar.
    private func chargeBarRange(for session: ChargeSessionSummary) -> (start: Double, end: Double)? {
        let start = session.startBatteryPercent
        let end = session.endBatteryPercent

        if let s = start, let e = end, e > s {
            return (s, e)
        }

        // Fall back to first / last checkpoint
        let sorted = session.checkpoints.sorted { $0.timestamp < $1.timestamp }
        if sorted.count >= 2,
           let first = sorted.first,
           let last = sorted.last,
           last.batteryPercent > first.batteryPercent {
            return (first.batteryPercent, last.batteryPercent)
        }

        return nil
    }

    private func chargeBarView(range: (start: Double, end: Double), tint: Color) -> some View {
        VStack(alignment: .leading, spacing: 4) {
            GeometryReader { geo in
                let w = geo.size.width
                let startX = w * CGFloat(range.start / 100)
                let fillWidth = max(w * CGFloat((range.end - range.start) / 100), 4)
                ZStack(alignment: .leading) {
                    Capsule()
                        .fill(Color.primary.opacity(0.08))
                    // Filled charged portion
                    Rectangle()
                        .fill(LinearGradient(
                            colors: [tint.opacity(0.6), tint],
                            startPoint: .leading,
                            endPoint: .trailing
                        ))
                        .frame(width: fillWidth)
                        .offset(x: startX)
                    // Start marker line
                    if range.start > 3 {
                        Rectangle()
                            .fill(Color.white.opacity(0.5))
                            .frame(width: 1.5, height: 14)
                            .offset(x: startX - 0.75)
                    }
                }
                .clipShape(Capsule())
            }
            .frame(height: 14)

            // Labels
            HStack {
                Text("\(Int(range.start.rounded()))%")
                    .font(.caption2)
                    .foregroundColor(.secondary)
                Spacer()
                Text("+\(Int((range.end - range.start).rounded()))%")
                    .font(.caption2.weight(.semibold))
                    .foregroundColor(tint)
                Spacer()
                Text("\(Int(range.end.rounded()))%")
                    .font(.caption2)
                    .foregroundColor(.secondary)
            }
        }
    }

    // MARK: - Chips

    private struct ChipContent {
        let label: String
        let tint: Color
    }

    private func chipContent(session: ChargeSessionSummary, capacityDelta: Double?) -> [ChipContent] {
        var chips: [ChipContent] = []

        if let capacityWh = session.capacityEstimateWh {
            var label = "\(capacityWh.format(decimalDigits: 1)) Wh"
            if let delta = capacityDelta {
                let sign = delta >= 0 ? "+" : ""
                label += " (\(sign)\(delta.format(decimalDigits: 1)))"
            }
            chips.append(ChipContent(label: label, tint: .orange))
        }

        if let batteryDelta = session.batteryDeltaPercent {
            let sign = batteryDelta >= 0 ? "+" : ""
            chips.append(ChipContent(label: "\(sign)\(Int(batteryDelta.rounded()))% charged", tint: .teal))
        }

        return chips
    }

    private func chipsRow(_ chips: [ChipContent]) -> some View {
        HStack(spacing: 6) {
            ForEach(chips.indices, id: \.self) { i in
                let chip = chips[i]
                Text(chip.label)
                    .font(.caption2.weight(.semibold))
                    .foregroundColor(chip.tint)
                    .padding(.horizontal, 8)
                    .padding(.vertical, 4)
                    .background(
                        RoundedRectangle(cornerRadius: 8)
                            .fill(chip.tint.opacity(0.14))
                            .overlay(RoundedRectangle(cornerRadius: 8).stroke(chip.tint.opacity(0.22), lineWidth: 1))
                    )
            }
            Spacer()
        }
    }

    // MARK: - Secondary info line

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

        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")
        }
        if session.wasConflictHealed {
            components.append("Auto-closed (sync conflict)")
        }
        components.append(session.sourceMode.title)

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

    // MARK: - Helpers

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