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 } ?? []
}
/// 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")
} 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")
}
}
// 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
}
}
}