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