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