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