// // ChargedDeviceSettingsView.swift // USB Meter // // Created by Codex on 10/04/2026. // import SwiftUI struct ChargedDeviceSettingsView: View { private enum DetailTab: Hashable { case overview case standby case sessions case trends case settings } @EnvironmentObject private var appData: AppData @Environment(\.dismiss) private var dismiss @State private var editorVisibility = false @State private var deleteConfirmationVisibility = false @State private var selectedTab: DetailTab = .overview @State private var sessionSelectMode = false @State private var selectedSessionIDs: Set = [] @State private var pendingBatchDeletion = false let chargedDeviceID: UUID var body: some View { Group { if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) { tabbedDetailView(chargedDevice) .navigationTitle(chargedDevice.name) .navigationBarTitleDisplayMode(.inline) } else { Text("This device is no longer available.") .foregroundColor(.secondary) .navigationTitle("Device") .navigationBarTitleDisplayMode(.inline) } } .sidebarToggleToolbarItem() .sheet(isPresented: $editorVisibility) { if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) { if chargedDevice.isCharger { ChargerEditorSheetView(chargedDevice: chargedDevice) .environmentObject(appData) } else { ChargedDeviceEditorSheetView( meterMACAddress: nil, chargedDevice: chargedDevice ) .environmentObject(appData) } } } .confirmationDialog("Delete \(deletionTitle)?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) { Button("Delete", role: .destructive) { if appData.deleteChargedDevice(id: chargedDeviceID) { dismiss() } } Button("Cancel", role: .cancel) {} } message: { Text(deletionMessage) } .confirmationDialog( "Delete \(selectedSessionIDs.count) Session\(selectedSessionIDs.count == 1 ? "" : "s")?", isPresented: $pendingBatchDeletion, titleVisibility: .visible ) { Button("Delete", role: .destructive, action: deleteSelectedSessions) Button("Cancel", role: .cancel) {} } message: { Text("Deleting these sessions also recalculates capacity and every derived metric that used them.") } } private func tabbedDetailView(_ chargedDevice: ChargedDeviceSummary) -> some View { GeometryReader { proxy in let tabs = availableTabs(for: chargedDevice) let displayedTab = displayedTab(from: tabs) let tabBarPresentation = AdaptiveTabBarPresentation.standard(for: proxy.size) VStack(spacing: 0) { ChargedDeviceDetailTabBarView( tabs: tabs, selection: $selectedTab, tint: tint(for: chargedDevice), presentation: tabBarPresentation, title: title(for:), systemImage: systemImage(for:) ) Group { if displayedTab == .sessions { sessionsTabLayout(chargedDevice) } else { ScrollView { tabContent(displayedTab, chargedDevice: chargedDevice) .padding() } } } .id(displayedTab) .transition(.opacity.combined(with: .move(edge: .trailing))) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } .animation(.easeInOut(duration: 0.22), value: displayedTab) .animation(.easeInOut(duration: 0.22), value: tabs) .onChange(of: selectedTab) { _ in sessionSelectMode = false selectedSessionIDs.removeAll() } } .background(detailBackground(for: chargedDevice)) .onAppear { ensureSelectedTabExists(for: chargedDevice) } .onChange(of: chargedDevice.isCharger) { _ in ensureSelectedTabExists(for: chargedDevice) } } @ViewBuilder private func tabContent(_ tab: DetailTab, chargedDevice: ChargedDeviceSummary) -> some View { VStack(spacing: 18) { switch tab { case .overview: overviewTab(chargedDevice) case .standby: standbyTab(chargedDevice) case .sessions: sessionsTab(chargedDevice) case .trends: trendsTab(chargedDevice) case .settings: settingsTab(chargedDevice) } } } @ViewBuilder private func overviewTab(_ chargedDevice: ChargedDeviceSummary) -> some View { headerCard(chargedDevice) insightsCard(chargedDevice) if let activeSession = chargedDevice.activeSession { activeSessionSummaryCard(activeSession, chargedDevice: chargedDevice) } } @ViewBuilder private func standbyTab(_ chargedDevice: ChargedDeviceSummary) -> some View { standbyPowerCard(chargedDevice) } @ViewBuilder private func sessionsTab(_ chargedDevice: ChargedDeviceSummary) -> some View { if let activeSession = chargedDevice.activeSession { activeSessionSummaryCard(activeSession, chargedDevice: chargedDevice) } let sessions = closedSessions(for: chargedDevice) if !sessions.isEmpty { sessionListCard(sessions, chargedDevice: chargedDevice) } else if chargedDevice.activeSession == nil { emptyStateCard( title: "No Sessions", message: "Charging sessions will appear here after this device is used in a recording.", tint: .teal ) } } @ViewBuilder private func trendsTab(_ chargedDevice: ChargedDeviceSummary) -> some View { if !chargedDevice.capacityHistory.isEmpty { capacityEvolutionCard(chargedDevice) } if !chargedDevice.typicalCurve.isEmpty { typicalCurveCard(chargedDevice) } if chargedDevice.capacityHistory.isEmpty && chargedDevice.typicalCurve.isEmpty { emptyStateCard( title: "Learning Trends", message: "Capacity history and charge curves will appear after enough completed sessions are available.", tint: .blue ) } } @ViewBuilder private func settingsTab(_ chargedDevice: ChargedDeviceSummary) -> some View { settingsCard(chargedDevice) } @ViewBuilder private func sessionsTabLayout(_ chargedDevice: ChargedDeviceSummary) -> some View { let allSessions = chargedDevice.sessions.sorted { lhs, rhs in let lOpen = lhs.status.isOpen, rOpen = rhs.status.isOpen if lOpen != rOpen { return lOpen } return lhs.startedAt > rhs.startedAt } let totalEnergyWh = allSessions.reduce(0.0) { $0 + $1.effectiveOrMeasuredEnergyWh } let totalDuration = allSessions.reduce(0.0) { $0 + max($1.effectiveDuration, 0) } VStack(spacing: 0) { // Fixed non-scrolling header VStack(spacing: 10) { sessionsSummaryStrip( count: allSessions.count, totalEnergyWh: totalEnergyWh, totalDuration: totalDuration, hasActive: chargedDevice.activeSession != nil ) if !allSessions.isEmpty { HStack(spacing: 12) { if sessionSelectMode && !selectedSessionIDs.isEmpty { Text("\(selectedSessionIDs.count) selected") .font(.subheadline) .foregroundColor(.secondary) .transition(.opacity.combined(with: .move(edge: .leading))) } Spacer() if sessionSelectMode && !selectedSessionIDs.isEmpty { Button { pendingBatchDeletion = true } label: { Image(systemName: "trash").foregroundColor(.red) } .transition(.opacity.combined(with: .scale)) } Button(sessionSelectMode ? "Cancel" : "Select") { withAnimation(.easeInOut(duration: 0.2)) { sessionSelectMode.toggle() if !sessionSelectMode { selectedSessionIDs.removeAll() } } } } .animation(.easeInOut(duration: 0.2), value: sessionSelectMode) .animation(.easeInOut(duration: 0.2), value: selectedSessionIDs.isEmpty) } } .padding() // Scrollable session list if allSessions.isEmpty { emptyStateCard( title: "No Sessions", message: "Charging sessions will appear here after this device is used in a recording.", tint: .teal ) .padding([.horizontal, .bottom]) } else { ScrollView { VStack(spacing: 10) { ForEach(allSessions, id: \.id) { session in sessionListItem(session, chargedDevice: chargedDevice) } } .padding([.horizontal, .bottom]) } } } } private func sessionsSummaryStrip( count: Int, totalEnergyWh: Double, totalDuration: TimeInterval, hasActive: Bool ) -> some View { HStack(spacing: 0) { summaryCell(value: "\(count)", label: count == 1 ? "session" : "sessions") Divider().frame(height: 30) summaryCell(value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh", label: "energy") Divider().frame(height: 30) summaryCell(value: formatAccumulatedDuration(totalDuration), label: "duration") if hasActive { Divider().frame(height: 30) HStack(spacing: 4) { Circle().fill(Color.green).frame(width: 6, height: 6) Text("Live") .font(.caption2.weight(.semibold)) .foregroundColor(.green) } .frame(maxWidth: .infinity) } } .padding(.vertical, 8) .padding(.horizontal, 12) .meterCard(tint: .teal, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 14) } private func summaryCell(value: String, label: String) -> some View { VStack(spacing: 2) { Text(value) .font(.subheadline.weight(.bold)) .foregroundColor(.primary) .monospacedDigit() .lineLimit(1) .minimumScaleFactor(0.7) Text(label) .font(.caption2) .foregroundColor(.secondary) } .frame(maxWidth: .infinity) } private func formatAccumulatedDuration(_ duration: TimeInterval) -> String { let formatter = DateComponentsFormatter() formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : [.minute, .second] formatter.unitsStyle = .abbreviated formatter.zeroFormattingBehavior = .dropAll return formatter.string(from: duration) ?? "0m" } private func headerCard(_ chargedDevice: ChargedDeviceSummary) -> some View { HStack(alignment: .top, spacing: 18) { ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 118) VStack(alignment: .leading, spacing: 10) { ChargedDeviceIdentityLabelView( chargedDevice: chargedDevice, iconPointSize: 22 ) .font(.title3.weight(.bold)) Text(chargedDevice.identityTitle) .font(.subheadline.weight(.semibold)) .foregroundColor(.secondary) if let meterMAC = chargedDevice.lastAssociatedMeterMAC { Text("Default meter: \(meterMAC)") .font(.caption) .foregroundColor(.secondary) } Text(chargedDevice.qrIdentifier) .font(.caption2.monospaced()) .foregroundColor(.secondary) .textSelection(.enabled) } Spacer(minLength: 0) } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.20, strokeOpacity: 0.26, cornerRadius: 20) } private func settingsCard(_ chargedDevice: ChargedDeviceSummary) -> some View { MeterInfoCardView(title: "Settings", tint: tint(for: chargedDevice)) { MeterInfoRowView( label: "Kind", value: chargedDevice.isCharger ? "Charger" : chargedDevice.deviceClass.title ) MeterInfoRowView(label: "Template", value: chargedDevice.identityTitle) MeterInfoRowView(label: "QR ID", value: chargedDevice.qrIdentifier) if let meterMAC = chargedDevice.lastAssociatedMeterMAC { MeterInfoRowView(label: "Default Meter", value: meterMAC) } MeterInfoRowView(label: "Created", value: chargedDevice.createdAt.format()) MeterInfoRowView(label: "Updated", value: chargedDevice.updatedAt.format()) Divider() Button(action: showEditor) { Label("Edit \(chargedDevice.isCharger ? "Charger" : "Device")", systemImage: "pencil") .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) } .buttonStyle(.plain) Button(role: .destructive, action: showDeleteConfirmation) { Label("Delete \(chargedDevice.isCharger ? "Charger" : "Device")", systemImage: "trash") .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .red, fillOpacity: 0.10, strokeOpacity: 0.18, cornerRadius: 14) } .buttonStyle(.plain) } } private func emptyStateCard(title: String, message: String, tint: Color) -> some View { VStack(alignment: .leading, spacing: 8) { Text(title) .font(.headline) Text(message) .font(.footnote) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: tint, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18) } private func insightsCard(_ chargedDevice: ChargedDeviceSummary) -> some View { MeterInfoCardView(title: "Insights", tint: tint(for: chargedDevice)) { if chargedDevice.isCharger { chargerInsights(chargedDevice) } else { deviceInsights(chargedDevice) } if let notes = chargedDevice.notes, !notes.isEmpty { Divider() Text(notes) .font(.footnote) .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } } } @ViewBuilder private func deviceInsights(_ chargedDevice: ChargedDeviceSummary) -> some View { if chargedDevice.hasMultipleChargingStateModes { MeterInfoRowView( label: "Charge Modes", value: chargedDevice.chargingStateAvailability.title ) } if chargedDevice.hasMultipleChargingTransports { MeterInfoRowView( label: "Charging Support", value: chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ") ) } if chargedDevice.showsWirelessProfileDetails { MeterInfoRowView( label: "Wireless Profile", value: chargedDevice.wirelessChargingProfile.title ) } ForEach(completionSessionKinds(for: chargedDevice), id: \.rawValue) { sessionKind in MeterInfoRowView( label: completionCurrentLabel(for: chargedDevice, sessionKind: sessionKind), value: completionCurrentDescription(for: chargedDevice, sessionKind: sessionKind) ) } MeterInfoRowView( label: "Estimated Capacity", value: chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Not enough data" ) if let wiredCapacity = chargedDevice.wiredEstimatedBatteryCapacityWh { if chargedDevice.hasMultipleChargingTransports { MeterInfoRowView( label: "Wired Capacity", value: "\(wiredCapacity.format(decimalDigits: 2)) Wh" ) } } if let wirelessCapacity = chargedDevice.wirelessEstimatedBatteryCapacityWh { if chargedDevice.hasMultipleChargingTransports { MeterInfoRowView( label: "Wireless Capacity", value: "\(wirelessCapacity.format(decimalDigits: 2)) Wh" ) } } if let wirelessEfficiencyFactor = chargedDevice.wirelessChargerEfficiencyFactor, chargedDevice.showsWirelessProfileDetails { MeterInfoRowView( label: "Wireless Efficiency", value: "\(Int((wirelessEfficiencyFactor * 100).rounded()))%" ) } MeterInfoRowView( label: "Charge Sessions", value: "\(chargedDevice.sessionCount)" ) } @ViewBuilder private func chargerInsights(_ chargedDevice: ChargedDeviceSummary) -> some View { if let chargerType = chargedDevice.chargerType { MeterInfoRowView( label: "Type", value: chargerType.title ) } if !chargedDevice.chargerObservedVoltageSelections.isEmpty { MeterInfoRowView( label: "Observed Voltages", value: chargedDevice.chargerObservedVoltageSelections .map { "\($0.format(decimalDigits: 1)) V" } .joined(separator: ", ") ) } if let chargerIdleCurrentAmps = chargedDevice.chargerIdleCurrentAmps { MeterInfoRowView( label: "Idle Current", value: "\(chargerIdleCurrentAmps.format(decimalDigits: 2)) A" ) } if let chargerEfficiencyFactor = chargedDevice.chargerEfficiencyFactor { MeterInfoRowView( label: "Efficiency", value: "\(Int((chargerEfficiencyFactor * 100).rounded()))%" ) } if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts { MeterInfoRowView( label: "Max Power", value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W" ) } if let latestStandbyPowerMeasurement = chargedDevice.latestStandbyPowerMeasurement { MeterInfoRowView( label: "Standby Power", value: "\(latestStandbyPowerMeasurement.averagePowerWatts.format(decimalDigits: 3)) W" ) MeterInfoRowView( label: "Standby Projection", value: standbyEnergyLabel(latestStandbyPowerMeasurement.projectedYearlyEnergyWh) + " / year" ) } MeterInfoRowView( label: "Wireless Sessions", value: "\(chargedDevice.sessionCount)" ) } private func standbyPowerCard(_ chargedDevice: ChargedDeviceSummary) -> some View { let latestMeasurement = chargedDevice.latestStandbyPowerMeasurement return MeterInfoCardView( title: "Standby Power", tint: .orange ) { if standbyMeasurementMeters.isEmpty { Text("Connect a meter first. Standby measurement is launched from a live meter feed, so only currently available meters can be selected here.") .font(.footnote) .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } else { NavigationLink( destination: ChargerStandbyPowerWizardView( preferredChargerID: chargedDevice.id, locksChargerSelection: true ) ) { Label("New Measurement", systemImage: "plus.circle.fill") .font(.subheadline.weight(.semibold)) .foregroundColor(.orange) } .buttonStyle(.plain) } if let latestMeasurement { Divider() NavigationLink( destination: ChargerStandbyPowerMeasurementDetailView( chargerID: chargedDevice.id, measurementID: latestMeasurement.id ) ) { VStack(alignment: .leading, spacing: 8) { HStack { Text("Latest Measurement") .font(.subheadline.weight(.semibold)) .foregroundColor(.primary) Spacer() Text("\(latestMeasurement.averagePowerWatts.format(decimalDigits: 3)) W") .font(.subheadline.weight(.bold)) .foregroundColor(.primary) .monospacedDigit() } Text( "\(latestMeasurement.endedAt.format()) • \(latestMeasurement.sampleCount) samples • \(standbyEnergyLabel(latestMeasurement.projectedYearlyEnergyWh)) / year" ) .font(.caption) .foregroundColor(.secondary) } } .buttonStyle(.plain) } if chargedDevice.standbyPowerMeasurements.isEmpty == false { Divider() NavigationLink( destination: ChargerStandbyPowerMeasurementsView(chargerID: chargedDevice.id) ) { Label("View Saved Measurements", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") .font(.subheadline.weight(.semibold)) .foregroundColor(.blue) } .buttonStyle(.plain) } } } private func activeSessionSummaryCard( _ activeSession: ChargeSessionSummary, chargedDevice: ChargedDeviceSummary ) -> some View { NavigationLink( destination: ChargeSessionDetailView( chargedDeviceID: chargedDevice.id, sessionID: activeSession.id ) ) { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 4) { Text("Current Session") .font(.headline) .foregroundColor(.primary) Text(activeSession.status.title) .font(.caption.weight(.semibold)) .foregroundColor(statusTint(for: activeSession)) } Spacer() Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } LazyVGrid(columns: activeSessionSummaryColumns, spacing: 8) { activeSessionMetricCell( label: "Energy", value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh", tint: .teal ) activeSessionMetricCell( label: "Duration", value: sessionDurationText(activeSession), tint: .orange ) if let maximumObservedPowerWatts = activeSession.maximumObservedPowerWatts { activeSessionMetricCell( label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W", tint: .blue ) } if let batteryPrediction = chargedDevice.batteryLevelPrediction(for: activeSession) { activeSessionMetricCell( label: "Battery", value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%", tint: .green ) } else if let targetBatteryPercent = activeSession.targetBatteryPercent { activeSessionMetricCell( label: "Target", value: "\(targetBatteryPercent.format(decimalDigits: 0))%", tint: .indigo ) } } Text("Started \(activeSession.startedAt.format())") .font(.caption) .foregroundColor(.secondary) } } .buttonStyle(.plain) .padding(18) .meterCard(tint: statusTint(for: activeSession), fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18) } private var activeSessionSummaryColumns: [GridItem] { [ GridItem(.flexible(minimum: 92), spacing: 8), GridItem(.flexible(minimum: 92), spacing: 8) ] } private func activeSessionMetricCell(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 capacityEvolutionCard(_ chargedDevice: ChargedDeviceSummary) -> some View { VStack(alignment: .leading, spacing: 12) { Text("Capacity Evolution") .font(.headline) ForEach(chargedDevice.capacityHistory.suffix(6)) { point in HStack { Text(point.timestamp.format()) .font(.caption) .foregroundColor(.secondary) Spacer() if chargedDevice.shouldShowChargingTransport(point.chargingTransportMode) { Text(point.chargingTransportMode.title) .font(.caption2) .foregroundColor(.secondary) Text("•") .foregroundColor(.secondary) } Text("\(point.capacityWh.format(decimalDigits: 2)) Wh") .font(.footnote.weight(.semibold)) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18) } private func typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View { VStack(alignment: .leading, spacing: 12) { Text("Typical Charge Curve") .font(.headline) ForEach(chargedDevice.typicalCurve) { point in HStack { Text("\(point.percentBin)%") .font(.footnote.weight(.semibold)) Spacer() Text("\(point.averageEnergyWh.format(decimalDigits: 2)) Wh") .font(.caption.weight(.semibold)) Text("•") .foregroundColor(.secondary) Text("\(point.sampleCount) sample\(point.sampleCount == 1 ? "" : "s")") .font(.caption2) .foregroundColor(.secondary) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18) } private func sessionListCard( _ sessions: [ChargeSessionSummary], chargedDevice: ChargedDeviceSummary ) -> some View { let totalEnergyWh = sessions.reduce(0) { $0 + $1.effectiveOrMeasuredEnergyWh } let completedCount = sessions.filter { $0.status == .completed }.count let sortedSessions = sessions.sorted { $0.startedAt > $1.startedAt } return VStack(alignment: .leading, spacing: 14) { MeterInfoCardView(title: "Closed Sessions", tint: .teal) { MeterInfoRowView(label: "Sessions", value: "\(sessions.count)") MeterInfoRowView(label: "Completed", value: "\(completedCount)") MeterInfoRowView(label: "Total Energy", value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh") } HStack(spacing: 12) { if sessionSelectMode && !selectedSessionIDs.isEmpty { Text("\(selectedSessionIDs.count) selected") .font(.subheadline) .foregroundColor(.secondary) .transition(.opacity.combined(with: .move(edge: .leading))) } Spacer() if sessionSelectMode && !selectedSessionIDs.isEmpty { Button { pendingBatchDeletion = true } label: { Image(systemName: "trash") .foregroundColor(.red) } .transition(.opacity.combined(with: .scale)) } Button(sessionSelectMode ? "Cancel" : "Select") { withAnimation(.easeInOut(duration: 0.2)) { sessionSelectMode.toggle() if !sessionSelectMode { selectedSessionIDs.removeAll() } } } } .animation(.easeInOut(duration: 0.2), value: sessionSelectMode) .animation(.easeInOut(duration: 0.2), value: selectedSessionIDs.isEmpty) VStack(spacing: 10) { ForEach(sortedSessions, id: \.id) { session in sessionListItem(session, chargedDevice: chargedDevice) } } } } private func sessionListItem( _ session: ChargeSessionSummary, chargedDevice: ChargedDeviceSummary ) -> some View { let sessionTint = statusTint(for: session) let isOpen = session.status.isOpen let isSelected = selectedSessionIDs.contains(session.id) return Group { if sessionSelectMode && !isOpen { Button { withAnimation(.easeInOut(duration: 0.15)) { if isSelected { selectedSessionIDs.remove(session.id) } else { selectedSessionIDs.insert(session.id) } } } label: { sessionRowContent(session, sessionTint: sessionTint, isOpen: isOpen, isSelected: isSelected) } .buttonStyle(.plain) } else { NavigationLink( destination: ChargeSessionDetailView( chargedDeviceID: chargedDevice.id, sessionID: session.id ) ) { sessionRowContent(session, sessionTint: sessionTint, isOpen: isOpen, isSelected: false) } .buttonStyle(.plain) } } } private func sessionRowContent( _ session: ChargeSessionSummary, sessionTint: Color, isOpen: Bool, isSelected: Bool ) -> some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline, spacing: 10) { if sessionSelectMode { Group { if isOpen { Image(systemName: "minus.circle") .foregroundColor(.secondary.opacity(0.35)) } else { Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .foregroundColor(isSelected ? .teal : .secondary) } } .font(.body) .transition(.opacity) } VStack(alignment: .leading, spacing: 2) { Text(session.startedAt.format()) .font(.subheadline.weight(.semibold)) Text(session.status.title) .font(.caption2) .foregroundColor(sessionTint) } Spacer() VStack(alignment: .trailing, spacing: 2) { Text("\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh") .font(.subheadline.weight(.semibold)) .foregroundColor(.primary) Text(sessionDurationText(session)) .font(.caption) .foregroundColor(.secondary) } } Divider() HStack(spacing: 8) { if let batteryDelta = session.batteryDeltaPercent { Label("\(batteryDelta >= 0 ? "+" : "")\(Int(batteryDelta.rounded()))% charged", systemImage: "battery.100percent") .font(.caption2) .foregroundColor(.secondary) } if let capacityWh = session.capacityEstimateWh { Text("est. \(capacityWh.format(decimalDigits: 1)) Wh") .font(.caption2) .foregroundColor(.secondary) } Spacer() if !session.displayedAggregatedSamples.isEmpty { Label("\(session.displayedAggregatedSamples.count) points", systemImage: "chart.xyaxis.line") .font(.caption2) .foregroundColor(.secondary) } } } .padding(12) .meterCard( tint: sessionTint, fillOpacity: isSelected ? 0.16 : (isOpen ? 0.14 : 0.08), strokeOpacity: isSelected ? 0.22 : (isOpen ? 0.30 : 0.14), cornerRadius: 14 ) } private func closedSessions(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionSummary] { chargedDevice.sessions.filter { !$0.status.isOpen } } private func availableTabs(for chargedDevice: ChargedDeviceSummary) -> [DetailTab] { if chargedDevice.isCharger { return [.overview, .standby, .settings] } return [.overview, .sessions, .trends, .settings] } private func displayedTab(from tabs: [DetailTab]) -> DetailTab { if tabs.contains(selectedTab) { return selectedTab } return tabs.first ?? .overview } private func ensureSelectedTabExists(for chargedDevice: ChargedDeviceSummary) { let tabs = availableTabs(for: chargedDevice) if !tabs.contains(selectedTab) { selectedTab = tabs.first ?? .overview } } private func title(for tab: DetailTab) -> String { switch tab { case .overview: return "Overview" case .standby: return "Standby" case .sessions: return "Sessions" case .trends: return "Trends" case .settings: return "Settings" } } private func systemImage(for tab: DetailTab) -> String { switch tab { case .overview: return "house.fill" case .standby: return "bolt.badge.clock" case .sessions: return "clock.arrow.trianglehead.counterclockwise.rotate.90" case .trends: return "chart.xyaxis.line" case .settings: return "gearshape.fill" } } private func detailBackground(for chargedDevice: ChargedDeviceSummary) -> some View { LinearGradient( colors: [tint(for: chargedDevice).opacity(0.18), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() } 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 } } 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 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 standbyEnergyLabel(_ wattHours: Double) -> String { if wattHours >= 1000 { return "\((wattHours / 1000).format(decimalDigits: 3)) kWh" } return "\(wattHours.format(decimalDigits: 2)) Wh" } private var standbyMeasurementMeters: [AppData.MeterSummary] { appData.meterSummaries.filter { $0.meter != nil } } private func completionCurrentDescription( for chargedDevice: ChargedDeviceSummary, sessionKind: ChargeSessionKind ) -> String { if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) { if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind), abs(configuredCurrent - learnedCurrent) >= 0.01 { return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned" } return "\(configuredCurrent.format(decimalDigits: 2)) A configured" } if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind) { return "\(learnedCurrent.format(decimalDigits: 2)) A learned" } return "Learning" } private func completionCurrentLabel( for chargedDevice: ChargedDeviceSummary, sessionKind: ChargeSessionKind ) -> String { let showsTransport = chargedDevice.shouldShowChargingTransport(sessionKind.chargingTransportMode) let showsState = chargedDevice.shouldShowChargingStateMode(sessionKind.chargingStateMode) switch (showsTransport, showsState) { case (true, true): return "\(sessionKind.shortTitle) Stop Current" case (true, false): return "\(sessionKind.chargingTransportMode.title) Stop Current" case (false, true): return "\(sessionKind.chargingStateMode.title) Stop Current" case (false, false): return "Stop Current" } } private func completionSessionKinds(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionKind] { chargedDevice.supportedChargingModes.flatMap { chargingTransportMode in chargedDevice.supportedChargingStateModes.map { chargingStateMode in ChargeSessionKind( chargingTransportMode: chargingTransportMode, chargingStateMode: chargingStateMode ) } } } private func deleteSelectedSessions() { for id in selectedSessionIDs { _ = appData.deleteChargeSession(sessionID: id) } selectedSessionIDs.removeAll() sessionSelectMode = false } private func showEditor() { editorVisibility = true } private func showDeleteConfirmation() { deleteConfirmationVisibility = true } private var deletionTitle: String { appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device" } private var deletionMessage: String { if appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true { return "This removes the charger from the library and unlinks it from wireless sessions that used it." } return "This removes the device and its stored charging history from the library." } }