1 contributor
//
// ChargedDeviceDetailView.swift
// USB Meter
//
// Created by Codex on 10/04/2026.
//
import SwiftUI
struct ChargedDeviceDetailView: 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
let chargedDeviceID: UUID
var body: some View {
Group {
if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
tabbedDetailView(chargedDevice)
.navigationTitle(chargedDevice.name)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button("Edit", action: showEditor)
Button(role: .destructive, action: showDeleteConfirmation) {
Image(systemName: "trash")
}
}
}
} else {
Text("This device is no longer available.")
.foregroundColor(.secondary)
.navigationTitle("Device")
}
}
.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)
}
}
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:)
)
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)
}
.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)
}
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
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")
}
VStack(spacing: 10) {
ForEach(sessions.sorted { $0.startedAt > $1.startedAt }, id: \.id) { session in
sessionListItem(session, chargedDevice: chargedDevice)
}
}
}
}
private func sessionListItem(
_ session: ChargeSessionSummary,
chargedDevice: ChargedDeviceSummary
) -> some View {
let sessionTint = statusTint(for: session)
return NavigationLink(
destination: ChargeSessionDetailView(
chargedDeviceID: chargedDevice.id,
sessionID: session.id
)
) {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
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: 0.08, strokeOpacity: 0.14, cornerRadius: 14)
}
.buttonStyle(.plain)
}
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 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."
}
}