// // MeterChargeRecordTabView.swift // USB Meter // import SwiftUI struct MeterChargeRecordTabView: View, Equatable { static func == (lhs: MeterChargeRecordTabView, rhs: MeterChargeRecordTabView) -> Bool { true } var body: some View { MeterChargeRecordContentView() } } struct MeterChargeRecordContentView: View { private enum InitialCheckpointMode: String, CaseIterable, Identifiable { case known case unknown case flat var id: String { rawValue } var title: String { switch self { case .known: return "Known" case .unknown: return "Unknown" case .flat: return "Flat" } } } private enum ActiveMode: Hashable { case chargeSession case standbyPower case consumptionMonitor } private enum SessionStartRequirement: Identifiable { case existingSession case device case chargingType case chargingMode case charger case initialCheckpointEmpty case initialCheckpointInvalid var id: String { switch self { case .existingSession: return "existing-session" case .device: return "device" case .chargingType: return "charging-type" case .chargingMode: return "charging-mode" case .charger: return "charger" case .initialCheckpointEmpty: return "initial-checkpoint-empty" case .initialCheckpointInvalid:return "initial-checkpoint-invalid" } } var message: String { switch self { case .existingSession: return "Stop or pause the current session before starting another one." case .device: return "Select the device that is charging." case .chargingType: return "Choose the charging type for this session." case .chargingMode: return "Choose whether the device is on or off for this session." case .charger: return "Select the wireless charger used in this session." case .initialCheckpointEmpty: return "Enter the initial battery percentage." case .initialCheckpointInvalid: return "Initial battery percentage must be between 0 and 100." } } } @EnvironmentObject private var appData: AppData @EnvironmentObject private var usbMeter: Meter @State private var draftChargingTransportMode: ChargingTransportMode? @State private var draftChargingStateMode: ChargingStateMode? @State private var initialCheckpointMode: InitialCheckpointMode = .known @State private var initialCheckpoint = "" @State private var showsMeterTotalsInfo = false @State private var activeMode: ActiveMode = .chargeSession @State private var draftChargedDeviceID: UUID? @State private var draftChargedPowerbankID: UUID? @State private var draftChargerID: UUID? @State private var draftSourcePowerbankID: UUID? @State private var draftConsumptionDeviceID: UUID? @State private var discardConsumptionConfirmation = false var body: some View { Group { if let openChargeSession { ChargeSessionDetailView( chargedDeviceID: openChargeSession.chargedDeviceID, sessionID: openChargeSession.id, monitoringMeter: usbMeter, presentation: .embedded ) } else if activeMode == .consumptionMonitor, let session = activeConsumptionSession { consumptionSessionActiveView(session) } else { ScrollView { VStack(spacing: 14) { statusHeader liveMeterStripView modePicker switch activeMode { case .chargeSession: chargeSessionSetupCard case .standbyPower: standbyPowerCard case .consumptionMonitor: consumptionMonitorSetupCard } } .padding() } } } .background( LinearGradient( colors: [.pink.opacity(0.14), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .confirmationDialog( "Stop and discard this session?", isPresented: $discardConsumptionConfirmation, titleVisibility: .visible ) { Button("Discard", role: .destructive) { _ = appData.stopConsumptionMonitor(for: meterMACAddress, save: false) } Button("Cancel", role: .cancel) {} } message: { Text("The current session data will be lost and nothing will be saved.") } .onAppear { syncDraftSelections() } .onChange(of: selectedChargeTargetID) { _ in syncDraftSelections() } .onChange(of: openChargeSession?.id) { _ in syncDraftSelections() } } // MARK: - Computed Properties private var meterMACAddress: String { usbMeter.btSerial.macAddress.description } private var selectedChargedDevice: ChargedDeviceSummary? { if let openChargeSession { return appData.chargedDeviceSummary(id: openChargeSession.chargedDeviceID) } guard draftChargedPowerbankID == nil else { return nil } guard let draftChargedDeviceID else { return nil } let chargedDevice = appData.chargedDeviceSummary(id: draftChargedDeviceID) return chargedDevice?.isCharger == false ? chargedDevice : nil } private var availableChargedDevices: [ChargedDeviceSummary] { appData.deviceSummaries } private var selectedChargedPowerbank: PowerbankSummary? { if let openChargeSession, let powerbankID = openChargeSession.chargedPowerbankID { return appData.powerbankSummaries.first { $0.id == powerbankID } } guard let draftChargedPowerbankID else { return nil } return appData.powerbankSummaries.first { $0.id == draftChargedPowerbankID } } private var selectedChargeTargetID: UUID? { selectedChargedPowerbank?.id ?? selectedChargedDevice?.id } private var selectedChargeTargetTag: Binding { Binding( get: { if let openChargeSession { if let powerbankID = openChargeSession.chargedPowerbankID { return "powerbank:\(powerbankID.uuidString)" } return "device:\(openChargeSession.chargedDeviceID.uuidString)" } if let draftChargedPowerbankID { return "powerbank:\(draftChargedPowerbankID.uuidString)" } if let draftChargedDeviceID { return "device:\(draftChargedDeviceID.uuidString)" } return "none" }, set: { newValue in if newValue == "none" { draftChargedDeviceID = nil draftChargedPowerbankID = nil draftChargingTransportMode = nil draftChargingStateMode = nil } else if newValue.hasPrefix("device:"), let uuid = UUID(uuidString: String(newValue.dropFirst("device:".count))) { draftChargedDeviceID = uuid draftChargedPowerbankID = nil } else if newValue.hasPrefix("powerbank:"), let uuid = UUID(uuidString: String(newValue.dropFirst("powerbank:".count))) { draftChargedDeviceID = nil draftChargedPowerbankID = uuid } } ) } private var selectedCharger: ChargedDeviceSummary? { if let openChargeSession, let chargerID = openChargeSession.chargerID { return appData.chargedDeviceSummary(id: chargerID) } guard let draftChargerID else { return nil } let charger = appData.chargedDeviceSummary(id: draftChargerID) return charger?.isCharger == true ? charger : nil } private var availableChargers: [ChargedDeviceSummary] { appData.chargerSummaries } private var availablePowerbanks: [PowerbankSummary] { appData.powerbankSummaries } private var availableSourcePowerbanks: [PowerbankSummary] { availablePowerbanks.filter { $0.id != selectedChargedPowerbank?.id } } private var selectedSourcePowerbank: PowerbankSummary? { if let openChargeSession, let powerbankID = openChargeSession.sourcePowerbankID { return availablePowerbanks.first { $0.id == powerbankID } } guard let draftSourcePowerbankID else { return nil } return availableSourcePowerbanks.first { $0.id == draftSourcePowerbankID } } /// Unified source selection encoding — packed into a String tag because SwiftUI Picker /// works best with hashable primitives. `none`, `charger:UUID`, or `powerbank:UUID`. private var selectedSourceTag: Binding { Binding( get: { if let openChargeSession { if let chargerID = openChargeSession.chargerID { return "charger:\(chargerID.uuidString)" } if let powerbankID = openChargeSession.sourcePowerbankID { return "powerbank:\(powerbankID.uuidString)" } return "none" } if let draftChargerID { return "charger:\(draftChargerID.uuidString)" } if let draftSourcePowerbankID { return "powerbank:\(draftSourcePowerbankID.uuidString)" } return "none" }, set: { newValue in if newValue == "none" { draftChargerID = nil draftSourcePowerbankID = nil } else if newValue.hasPrefix("charger:"), let uuid = UUID(uuidString: String(newValue.dropFirst("charger:".count))) { draftChargerID = uuid draftSourcePowerbankID = nil } else if newValue.hasPrefix("powerbank:"), let uuid = UUID(uuidString: String(newValue.dropFirst("powerbank:".count))) { draftChargerID = nil draftSourcePowerbankID = uuid } } ) } private var hasAnySource: Bool { availableChargers.isEmpty == false || availableSourcePowerbanks.isEmpty == false } private var selectedChargerID: Binding { Binding( get: { openChargeSession?.chargerID ?? draftChargerID }, set: { newValue in draftChargerID = newValue } ) } private var openChargeSession: ChargeSessionSummary? { appData.activeChargeSessionSummary(for: meterMACAddress) } private var activeConsumptionSession: ConsumptionMonitorLiveSession? { appData.consumptionMonitorSession(for: meterMACAddress) } private var draftConsumptionDevice: ChargedDeviceSummary? { guard let id = draftConsumptionDeviceID else { return nil } return availableChargedDevices.first { $0.id == id } } private var showsMeterTotalsCard: Bool { usbMeter.supportsRecordingView || usbMeter.supportsDataGroupCommands || usbMeter.recordedAH > 0 || usbMeter.recordedWH > 0 || usbMeter.recordingDuration > 0 } private var selectedDraftTransportMode: ChargingTransportMode? { openChargeSession?.chargingTransportMode ?? draftChargingTransportMode } private var selectedDraftChargingStateMode: ChargingStateMode? { openChargeSession?.chargingStateMode ?? draftChargingStateMode } private var initialCheckpointValue: Double? { guard initialCheckpointMode == .known else { return nil } let normalized = initialCheckpoint .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: ",", with: ".") guard let value = Double(normalized), value >= 0, value <= 100 else { return nil } return value } private var hasInitialCheckpointInput: Bool { initialCheckpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } private var shouldRequireInitialCheckpoint: Bool { initialCheckpointMode == .known } private var requiresExplicitTransportSelection: Bool { (selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1 } private var requiresExplicitChargingStateSelection: Bool { (selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1 } private var startRequirements: [SessionStartRequirement] { var requirements: [SessionStartRequirement] = [] if openChargeSession != nil { requirements.append(.existingSession) } if selectedChargedPowerbank != nil { if shouldRequireInitialCheckpoint { if hasInitialCheckpointInput == false { requirements.append(.initialCheckpointEmpty) } else if initialCheckpointValue == nil { requirements.append(.initialCheckpointInvalid) } } return requirements } guard let selectedChargedDevice else { requirements.append(.device) return requirements } guard let chargingTransportMode = selectedDraftTransportMode else { requirements.append(.chargingType) return requirements } if selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) == false { requirements.append(.chargingType) } guard let chargingStateMode = selectedDraftChargingStateMode else { requirements.append(.chargingMode) return requirements } if selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) == false { requirements.append(.chargingMode) } if chargingTransportMode == .wireless, selectedCharger == nil { requirements.append(.charger) } if shouldRequireInitialCheckpoint { if hasInitialCheckpointInput == false { requirements.append(.initialCheckpointEmpty) } else if initialCheckpointValue == nil { requirements.append(.initialCheckpointInvalid) } } return requirements } private var canStartSession: Bool { startRequirements.isEmpty } private var headerStatusTitle: String { guard let openChargeSession else { return "Idle" } return openChargeSession.status.title } private var headerStatusColor: Color { guard let openChargeSession else { return .secondary } switch openChargeSession.status { case .active: return .red case .paused: return .orange case .completed: return .green case .abandoned: return .secondary } } private var showsWirelessChargerSection: Bool { let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first return transportMode == .wireless } /// Source section is visible whenever a transport is picked. For wired sessions only /// powerbanks are listed (chargers don't apply); for wireless both chargers and powerbanks /// can be picked. private var showsSourceSection: Bool { guard selectedDraftTransportMode != nil || selectedChargedDevice != nil else { return false } if showsWirelessChargerSection { return hasAnySource } return availableSourcePowerbanks.isEmpty == false } private var sourceSectionListsChargers: Bool { showsWirelessChargerSection } private var sourcePromptText: String { if showsWirelessChargerSection { return availableChargers.isEmpty && availablePowerbanks.isEmpty ? "No source available" : "Choose source" } return availableSourcePowerbanks.isEmpty ? "No powerbank available" : "Choose powerbank (optional)" } // MARK: - Status Header private var statusHeader: some View { HStack { Image(systemName: "bolt.fill") .foregroundColor(.pink) Text("Charging Session") .font(.system(.title3, design: .rounded).weight(.bold)) Spacer() Text(headerStatusTitle) .font(.caption.weight(.bold)) .foregroundColor(headerStatusColor) .padding(.horizontal, 10) .padding(.vertical, 6) .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999) } .padding(.horizontal, 18) .padding(.vertical, 12) .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24) } // MARK: - Mode Picker private var modePicker: some View { Picker("", selection: $activeMode) { Label("Charge Session", systemImage: "bolt.fill").tag(ActiveMode.chargeSession) Label("Standby Power", systemImage: "powersleep").tag(ActiveMode.standbyPower) Label("Consumption", systemImage: "chart.line.uptrend.xyaxis").tag(ActiveMode.consumptionMonitor) } .pickerStyle(.segmented) .labelsHidden() } // MARK: - Charge Session Setup private var chargeSessionSetupCard: some View { VStack(alignment: .leading, spacing: 0) { // Device setupRow(icon: "iphone", iconColor: .blue) { Picker(selection: selectedChargeTargetTag) { Text("Choose target").tag("none") ForEach(availableChargedDevices) { device in Text(device.name).tag("device:\(device.id.uuidString)") } ForEach(availablePowerbanks) { powerbank in Text("Powerbank · \(powerbank.name)").tag("powerbank:\(powerbank.id.uuidString)") } } label: { HStack(spacing: 8) { if let device = selectedChargedDevice { ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15) .font(.subheadline.weight(.semibold)) } else if let powerbank = selectedChargedPowerbank { Label(powerbank.name, systemImage: powerbank.identitySymbolName) .font(.subheadline.weight(.semibold)) } else { Text(availableChargedDevices.isEmpty && availablePowerbanks.isEmpty ? "No targets available" : "Choose target") .foregroundColor(.secondary) .font(.subheadline) } Spacer(minLength: 8) Image(systemName: "chevron.up.chevron.down") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } } .pickerStyle(.menu) .disabled(availableChargedDevices.isEmpty && availablePowerbanks.isEmpty) } // Charging type — only when device supports multiple if requiresExplicitTransportSelection, let device = selectedChargedDevice { Divider().padding(.leading, 46) setupRow(icon: draftChargingTransportMode?.symbolName ?? "bolt.slash", iconColor: .orange) { Text("Type") .foregroundColor(.secondary) .font(.subheadline) Spacer() compactSelectionMenu( title: draftChargingTransportMode?.title ?? "Choose", options: device.supportedChargingModes.map { mode in CompactSelectionOption( id: mode.id, title: mode.title, isSelected: draftChargingTransportMode == mode, action: { draftChargingTransportMode = mode } ) } ) } } // Source — charger (when wireless) and/or powerbank. None is always allowed. if showsSourceSection { Divider().padding(.leading, 46) .transition(.opacity) setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) { Picker(selection: selectedSourceTag) { Text("None").tag("none") if sourceSectionListsChargers { ForEach(availableChargers) { charger in Text("Charger · \(charger.name)").tag("charger:\(charger.id.uuidString)") } } ForEach(availableSourcePowerbanks) { powerbank in Text("Powerbank · \(powerbank.name)").tag("powerbank:\(powerbank.id.uuidString)") } } label: { HStack(spacing: 8) { if let charger = selectedCharger { ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15) .font(.subheadline.weight(.semibold)) } else if let powerbank = selectedSourcePowerbank { Label(powerbank.name, systemImage: powerbank.identitySymbolName) .font(.subheadline.weight(.semibold)) } else { Text(sourcePromptText) .foregroundColor(.secondary) .font(.subheadline) } Spacer(minLength: 8) Image(systemName: "chevron.up.chevron.down") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } } .pickerStyle(.menu) } .transition(.asymmetric( insertion: .move(edge: .top).combined(with: .opacity), removal: .opacity )) } // Charging state — only when device supports multiple if requiresExplicitChargingStateSelection, let device = selectedChargedDevice { Divider().padding(.leading, 46) setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) { Text("Mode") .foregroundColor(.secondary) .font(.subheadline) Spacer() compactSelectionMenu( title: draftChargingStateMode?.title ?? "Choose", options: device.supportedChargingStateModes.map { mode in CompactSelectionOption( id: mode.id, title: mode.title, isSelected: draftChargingStateMode == mode, action: { draftChargingStateMode = mode } ) } ) } } // Battery checkpoint Divider().padding(.leading, 46) setupRow(icon: "battery.75percent", iconColor: .green) { if initialCheckpointMode == .known { Button { adjustInitialCheckpoint(by: -1) } label: { Image(systemName: "minus.circle").font(.title3) } .buttonStyle(.plain) TextField("—", text: $initialCheckpoint) .keyboardType(.decimalPad) .textFieldStyle(.roundedBorder) .frame(width: 52) .multilineTextAlignment(.center) Text("%") .font(.subheadline) .foregroundColor(.secondary) Button { adjustInitialCheckpoint(by: 1) } label: { Image(systemName: "plus.circle").font(.title3) } .buttonStyle(.plain) } else { Text(initialCheckpointMode == .flat ? "Flat (device off / discharged)" : "Unknown") .font(.subheadline) .foregroundColor(.secondary) } Spacer() compactSelectionMenu( title: initialCheckpointMode.title, options: InitialCheckpointMode.allCases.map { mode in CompactSelectionOption( id: mode.id, title: mode.title, isSelected: initialCheckpointMode == mode, action: { initialCheckpointMode = mode } ) } ) } // Requirement errors if startRequirements.isEmpty == false { Divider() VStack(alignment: .leading, spacing: 6) { ForEach(startRequirements) { requirement in Label(requirement.message, systemImage: "exclamationmark.circle") .font(.caption) .foregroundColor(.orange) } } .padding(.horizontal, 14) .padding(.vertical, 10) } // Start button Divider() Button("Start Session") { startSession() } .frame(maxWidth: .infinity) .padding(.vertical, 11) .font(.subheadline.weight(.semibold)) .foregroundColor(canStartSession ? .green : .secondary) .buttonStyle(.plain) .disabled(!canStartSession) } .animation(.spring(response: 0.35, dampingFraction: 0.8), value: showsWirelessChargerSection) .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20) } // MARK: - Consumption Monitor private var consumptionMonitorSetupCard: some View { VStack(alignment: .leading, spacing: 0) { setupRow(icon: "iphone", iconColor: .purple) { Picker(selection: $draftConsumptionDeviceID) { Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device") .tag(Optional.none) ForEach(availableChargedDevices) { device in Text(device.name).tag(Optional(device.id)) } } label: { HStack(spacing: 8) { if let device = draftConsumptionDevice { ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15) .font(.subheadline.weight(.semibold)) } else { Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device") .foregroundColor(.secondary) .font(.subheadline) } Spacer(minLength: 8) Image(systemName: "chevron.up.chevron.down") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } } .pickerStyle(.menu) .disabled(availableChargedDevices.isEmpty) } Divider() Button("Start Session") { startConsumptionSession() } .frame(maxWidth: .infinity) .padding(.vertical, 11) .font(.subheadline.weight(.semibold)) .foregroundColor(draftConsumptionDeviceID != nil ? .purple : .secondary) .buttonStyle(.plain) .disabled(draftConsumptionDeviceID == nil) } .meterCard(tint: .purple, fillOpacity: 0.14, strokeOpacity: 0.20) } private func consumptionSessionActiveView(_ session: ConsumptionMonitorLiveSession) -> some View { ScrollView { VStack(spacing: 14) { consumptionSessionHeaderCard liveMeterStripView consumptionSessionInfoCard(session) if session.cumulativeEnergyWh > 0 { consumptionProjectionsCard(session) } } .padding() } } private var consumptionSessionHeaderCard: some View { HStack { Image(systemName: "chart.line.uptrend.xyaxis") .foregroundColor(.purple) Text("Consumption Monitor") .font(.system(.title3, design: .rounded).weight(.bold)) Spacer() Text("Running") .font(.caption.weight(.bold)) .foregroundColor(.green) .padding(.horizontal, 10) .padding(.vertical, 6) .meterCard(tint: .green, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999) } .padding(.horizontal, 18) .padding(.vertical, 12) .meterCard(tint: .purple, fillOpacity: 0.18, strokeOpacity: 0.24) } private func consumptionSessionInfoCard(_ session: ConsumptionMonitorLiveSession) -> some View { VStack(alignment: .leading, spacing: 0) { if let device = appData.chargedDeviceSummary(id: session.chargedDeviceID) { setupRow(icon: "iphone", iconColor: .purple) { ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15) .font(.subheadline.weight(.semibold)) Spacer() } Divider().padding(.leading, 46) } setupRow(icon: "clock", iconColor: .secondary) { Text("Duration") .foregroundColor(.secondary) .font(.subheadline) Spacer() Text(consumptionDurationText(session.elapsedDuration)) .font(.subheadline.weight(.semibold)) .monospacedDigit() } Divider().padding(.leading, 46) setupRow(icon: "waveform", iconColor: .secondary) { Text("Samples") .foregroundColor(.secondary) .font(.subheadline) Spacer() Text("\(session.committedSampleCount) × 60 s") .font(.subheadline.weight(.semibold)) .monospacedDigit() } Divider().padding(.leading, 46) setupRow(icon: "bolt.fill", iconColor: .yellow) { Text("Energy") .foregroundColor(.secondary) .font(.subheadline) Spacer() Text(consumptionEnergyText(session.cumulativeEnergyWh)) .font(.subheadline.weight(.semibold)) .monospacedDigit() } Divider() HStack(spacing: 0) { Button("Save & Stop") { _ = appData.stopConsumptionMonitor(for: meterMACAddress, save: true) } .frame(maxWidth: .infinity) .padding(.vertical, 11) .font(.subheadline.weight(.semibold)) .foregroundColor(session.committedSampleCount > 0 ? .green : .secondary) .buttonStyle(.plain) .disabled(session.committedSampleCount == 0) Divider().frame(height: 42) Button("Discard") { discardConsumptionConfirmation = true } .frame(maxWidth: .infinity) .padding(.vertical, 11) .font(.subheadline.weight(.semibold)) .foregroundColor(.red) .buttonStyle(.plain) } } .meterCard(tint: .purple, fillOpacity: 0.14, strokeOpacity: 0.20) } private func consumptionProjectionsCard(_ session: ConsumptionMonitorLiveSession) -> some View { let avgPower = session.cumulativeEnergyWh / max(session.elapsedDuration / 3600, 0.001) return VStack(alignment: .leading, spacing: 0) { setupRow(icon: "chart.bar.fill", iconColor: .teal) { Text("Avg Power") .foregroundColor(.secondary) .font(.subheadline) Spacer() Text("\(avgPower.format(decimalDigits: 3)) W") .font(.subheadline.weight(.semibold)) .monospacedDigit() } Divider().padding(.leading, 46) setupRow(icon: "calendar.day.timeline.right", iconColor: .teal) { Text("24 Hours") .foregroundColor(.secondary) .font(.subheadline) Spacer() Text(consumptionEnergyText(avgPower * 24)) .font(.subheadline.weight(.semibold)) .monospacedDigit() } Divider().padding(.leading, 46) setupRow(icon: "calendar", iconColor: .teal) { Text("30 Days") .foregroundColor(.secondary) .font(.subheadline) Spacer() Text(consumptionEnergyText(avgPower * 24 * 30)) .font(.subheadline.weight(.semibold)) .monospacedDigit() } Divider().padding(.leading, 46) setupRow(icon: "calendar", iconColor: .teal) { Text("1 Year") .foregroundColor(.secondary) .font(.subheadline) Spacer() Text(consumptionEnergyText(avgPower * 24 * 365)) .font(.subheadline.weight(.semibold)) .monospacedDigit() } } .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20) } // MARK: - Standby Power Card private var standbyPowerCard: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 10) { Image(systemName: "powersleep") .foregroundColor(.orange) .font(.title3) VStack(alignment: .leading, spacing: 2) { Text("Charger Standby Power") .font(.subheadline.weight(.semibold)) Text("Measure idle draw with no device connected.") .font(.caption) .foregroundColor(.secondary) } } NavigationLink( destination: ChargerStandbyPowerWizardView( preferredMeterMACAddress: meterMACAddress ) ) { HStack { Image(systemName: "plus.circle.fill") .foregroundColor(.orange) Text("New Measurement") .font(.subheadline.weight(.semibold)) Spacer() Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } .padding(.vertical, 10) .padding(.horizontal, 14) .meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14) } .buttonStyle(.plain) } .padding(18) .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16) } // MARK: - Live Meter Strip (idle state) private var liveMeterStripView: some View { let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())] return LazyVGrid(columns: columns, spacing: 8) { metricCell(label: "Power", value: "\(usbMeter.power.format(decimalDigits: 2)) W", tint: .yellow) metricCell(label: "Current", value: "\(usbMeter.current.format(decimalDigits: 3)) A", tint: .blue) metricCell(label: "Voltage", value: "\(usbMeter.voltage.format(decimalDigits: 2)) V", tint: .teal) } } private func metricCell(label: String, value: String, tint: Color) -> some View { VStack(alignment: .leading, spacing: 3) { Text(label) .font(.caption2) .foregroundColor(.secondary) Text(value) .font(.subheadline.weight(.semibold)) .lineLimit(1) .minimumScaleFactor(0.7) .monospacedDigit() } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) .padding(.vertical, 10) .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12) } private var meterTotalsCard: some View { return VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { Text("Meter Recorder") .font(.headline) Spacer(minLength: 0) Button { showsMeterTotalsInfo.toggle() } label: { Image(systemName: "info.circle") .font(.body.weight(.semibold)) .foregroundColor(.secondary) } .buttonStyle(.plain) .accessibilityLabel("Meter recorder info") .popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) { VStack(alignment: .leading, spacing: 10) { Text("Meter Recorder") .font(.headline) Text("These values come directly from the meter's built-in recorder. Keep them visible while comparing the app session against what the meter captured on its own.") .font(.body) .fixedSize(horizontal: false, vertical: true) } .padding(16) .frame(width: 280, alignment: .leading) } } ChargeRecordMetricsTableView( labels: ["Energy", "Duration", "Meter Threshold"], values: [ "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh", usbMeter.recordingDurationDescription, usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only" ] ) if let recordingBootedAt = usbMeter.recordingBootedAt { Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).") .font(.caption) .foregroundColor(.secondary) } } .padding(18) .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20) } // MARK: - Helpers private func setupRow( icon: String, iconColor: Color = .secondary, @ViewBuilder content: () -> Content ) -> some View { HStack(spacing: 10) { Image(systemName: icon) .foregroundColor(iconColor) .font(.body.weight(.medium)) .frame(width: 22, alignment: .center) content() } .padding(.horizontal, 14) .padding(.vertical, 11) } private func startSession() { if let selectedChargedPowerbank { let didStart = appData.startPowerbankChargeSession( for: usbMeter, powerbankID: selectedChargedPowerbank.id, sourcePowerbankID: selectedSourcePowerbank?.id, initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil, startsFromFlatBattery: initialCheckpointMode == .flat ) if didStart { initialCheckpoint = "" initialCheckpointMode = .known } return } guard let selectedChargedDevice, let chargingTransportMode = selectedDraftTransportMode, let chargingStateMode = selectedDraftChargingStateMode else { return } let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil let powerbankSourceID = selectedSourcePowerbank?.id let didStart = appData.startChargeSession( for: usbMeter, chargedDeviceID: selectedChargedDevice.id, chargerID: chargerID, sourcePowerbankID: powerbankSourceID, chargingTransportMode: chargingTransportMode, chargingStateMode: chargingStateMode, autoStopEnabled: false, initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil, startsFromFlatBattery: initialCheckpointMode == .flat ) if didStart { initialCheckpoint = "" initialCheckpointMode = .known } } private func startConsumptionSession() { guard let deviceID = draftConsumptionDeviceID else { return } _ = appData.startConsumptionMonitor(for: deviceID, on: usbMeter) } private func adjustInitialCheckpoint(by delta: Double) { guard initialCheckpointMode == .known else { return } let currentValue = initialCheckpointValue ?? 0 let nextValue = min(max(currentValue + delta, 0), 100) initialCheckpoint = nextValue.format(decimalDigits: 0) } private func syncDraftSelections() { if selectedChargedPowerbank != nil { draftChargingTransportMode = .wired draftChargingStateMode = .on if draftSourcePowerbankID == selectedChargedPowerbank?.id { draftSourcePowerbankID = nil } return } guard let selectedChargedDevice else { draftChargingTransportMode = nil draftChargingStateMode = nil return } if let openChargeSession { draftChargingTransportMode = openChargeSession.chargingTransportMode draftChargingStateMode = openChargeSession.chargingStateMode return } if let draftChargingTransportMode, selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false { self.draftChargingTransportMode = nil } if let draftChargingStateMode, selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false { self.draftChargingStateMode = nil } if selectedChargedDevice.supportedChargingModes.count == 1 { draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first } if let draftChargingTransportMode { draftChargingStateMode = draftChargingStateMode ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode) } else if selectedChargedDevice.supportedChargingStateModes.count == 1 { draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first } } private struct CompactSelectionOption: Identifiable { let id: String let title: String let isSelected: Bool let action: () -> Void } private func compactSelectionMenu( title: String, options: [CompactSelectionOption] ) -> some View { Menu { ForEach(options) { option in Button { option.action() } label: { if option.isSelected { Label(option.title, systemImage: "checkmark") } else { Text(option.title) } } } } label: { HStack(spacing: 8) { Text(title) .foregroundColor(.primary) Spacer() Image(systemName: "chevron.up.chevron.down") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } .padding(.horizontal, 12) .padding(.vertical, 9) .frame(width: 160, alignment: .leading) .meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12) } .buttonStyle(.plain) } private func consumptionDurationText(_ duration: TimeInterval) -> String { let formatter = DateComponentsFormatter() formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : [.minute, .second] formatter.unitsStyle = .abbreviated formatter.zeroFormattingBehavior = .pad return formatter.string(from: max(duration, 0)) ?? "0m" } private func consumptionEnergyText(_ wattHours: Double) -> String { wattHours >= 1000 ? "\((wattHours / 1000).format(decimalDigits: 3)) kWh" : "\(wattHours.format(decimalDigits: 2)) Wh" } }