// // 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 struct SessionMetricRow { let label: String let value: String } 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 } private enum FinalCheckpoint: Hashable { case full case skip case custom var label: String { switch self { case .full: return "Full" case .skip: return "Skip" case .custom: return "Other %" } } var icon: String { switch self { case .full: return "battery.100percent" case .skip: return "minus.circle" case .custom: return "pencil" } } } 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 showingInlineTargetEditor = false @State private var draftTargetText = "" @State private var showingStopConfirm = false @State private var finalCheckpointMode: FinalCheckpoint = .skip @State private var finalCheckpointText = "" @State private var pendingCheckpointDeletion: ChargeCheckpointSummary? @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 detectedTrimWindow: ChargingWindowDetector.DetectedWindow? @State private var trimBannerDismissedForSessionID: UUID? private var shouldShowTrimBanner: Bool { guard let session = openChargeSession, session.isTrimmed == false, trimBannerDismissedForSessionID != session.id else { return false } guard let window = detectedTrimWindow else { return false } return window.trimRatio > ChargingWindowDetector.significantTrimThreshold } var body: some View { ScrollView { VStack(spacing: 14) { statusHeader if let openChargeSession { chargingMonitorCard(openChargeSession) if shouldShowTrimBanner { trimDetectionBanner(for: openChargeSession) } if shouldShowSessionChart(for: openChargeSession) { sessionChartCard( timeRange: sessionChartFixedTimeRange(for: openChargeSession), session: openChargeSession ) } } else { liveMeterStripView modePicker switch activeMode { case .chargeSession: chargeSessionSetupCard case .standbyPower: standbyPowerCard } } } .padding() } .background( LinearGradient( colors: [.pink.opacity(0.14), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .alert(item: $pendingCheckpointDeletion) { checkpoint in Alert( title: Text("Delete Battery Checkpoint"), message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"), primaryButton: .destructive(Text("Delete")) { if let openChargeSession { _ = appData.deleteBatteryCheckpoint( checkpointID: checkpoint.id, for: openChargeSession.id ) } }, secondaryButton: .cancel() ) } .onAppear { syncActiveSessionRestore() syncDraftSelections() runTrimDetection() } .onChange(of: selectedChargedDevice?.id) { _ in syncDraftSelections() } .onChange(of: openChargeSession?.id) { _ in syncActiveSessionRestore() syncDraftSelections() showingInlineTargetEditor = false draftTargetText = "" detectedTrimWindow = nil trimBannerDismissedForSessionID = nil runTrimDetection() } .onChange(of: openChargeSession?.aggregatedSamples.count) { _ in syncActiveSessionRestore() runTrimDetection() } } private func syncActiveSessionRestore() { guard let session = openChargeSession else { return } guard session.status == .active else { return } guard session.meterMACAddress == meterMACAddress else { return } usbMeter.restoreChargeRecordIfNeeded(from: session) } private func runTrimDetection() { guard let session = openChargeSession, session.isTrimmed == false, !session.aggregatedSamples.isEmpty else { detectedTrimWindow = nil return } let sessionEnd = session.endedAt ?? session.lastObservedAt detectedTrimWindow = ChargingWindowDetector.detect( samples: session.aggregatedSamples, sessionStart: session.startedAt, sessionEnd: sessionEnd ) } // MARK: - Computed Properties private var meterMACAddress: String { usbMeter.btSerial.macAddress.description } private var selectedChargedDevice: ChargedDeviceSummary? { appData.currentChargedDeviceSummary(for: meterMACAddress) } private var availableChargedDevices: [ChargedDeviceSummary] { appData.deviceSummaries } private var selectedChargedDeviceID: Binding { Binding( get: { selectedChargedDevice?.id }, set: { newValue in guard let newValue else { return } _ = appData.assignChargedDevice(newValue, to: meterMACAddress) } ) } private var selectedCharger: ChargedDeviceSummary? { appData.currentChargerSummary(for: meterMACAddress) } private var availableChargers: [ChargedDeviceSummary] { appData.chargerSummaries } private var selectedChargerID: Binding { Binding( get: { selectedCharger?.id }, set: { newValue in guard let newValue else { return } _ = appData.assignCharger(newValue, to: meterMACAddress) } ) } private var openChargeSession: ChargeSessionSummary? { appData.activeChargeSessionSummary(for: meterMACAddress) } 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) } 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 func shouldShowSessionChart(for session: ChargeSessionSummary) -> Bool { sessionChartFixedTimeRange(for: session) != nil || usesChargeRecordBuffer(for: session) } private func sessionChartFixedTimeRange(for session: ChargeSessionSummary) -> ClosedRange? { if usesChargeRecordBuffer(for: session) { return nil } return session.effectiveTimeRange } private func sessionChartLiveTrimBounds(for session: ChargeSessionSummary) -> (lower: Date?, upper: Date?) { guard usesChargeRecordBuffer(for: session) else { return (nil, nil) } return (session.trimStart, session.trimEnd) } private func usesChargeRecordBuffer(for session: ChargeSessionSummary) -> Bool { session.status.isOpen && session.meterMACAddress == meterMACAddress } private var showsWirelessChargerSection: Bool { let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first return transportMode == .wireless } // 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) } .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: selectedChargedDeviceID) { Text("Choose device").tag(UUID?.none) ForEach(availableChargedDevices) { device in Text(device.name).tag(Optional(device.id)) } } label: { HStack(spacing: 8) { if let device = selectedChargedDevice { 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) } // 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 } ) } ) } } // 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 } ) } ) } } // Wireless charger — only when wireless transport if showsWirelessChargerSection { Divider().padding(.leading, 46) setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) { Picker(selection: selectedChargerID) { Text("Choose charger").tag(UUID?.none) ForEach(availableChargers) { charger in Text(charger.name).tag(Optional(charger.id)) } } label: { HStack(spacing: 8) { if let charger = selectedCharger { ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15) .font(.subheadline.weight(.semibold)) if charger.chargerIdleCurrentAmps == nil { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) .font(.caption) } } else { Text(availableChargers.isEmpty ? "No chargers available" : "Choose charger") .foregroundColor(.secondary) .font(.subheadline) } Spacer(minLength: 8) Image(systemName: "chevron.up.chevron.down") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } } .pickerStyle(.menu) .disabled(availableChargers.isEmpty) } } // 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) } .meterCard(tint: .orange, 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) } } // MARK: - Charging Monitor Card private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View { let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession) let displayedChargeAh = displayedSessionChargeAh(for: openChargeSession) let canAddCheckpoint = appData.canAddBatteryCheckpoint(to: openChargeSession.id) let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction( for: openChargeSession, effectiveEnergyWhOverride: displayedEnergyWh ) return VStack(alignment: .leading, spacing: 14) { // Header HStack { if let device = selectedChargedDevice { ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 16) .font(.headline) } else { Text("Charging Monitor").font(.headline) } Spacer() Text(openChargeSession.status.title) .font(.caption.weight(.bold)) .foregroundColor(headerStatusColor) .padding(.horizontal, 8) .padding(.vertical, 4) .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999) } // Orphaned session warning — device was deleted from library if selectedChargedDevice == nil { VStack(alignment: .leading, spacing: 8) { Label("Device removed from library", systemImage: "exclamationmark.triangle.fill") .font(.subheadline.weight(.semibold)) .foregroundColor(.orange) Text("The device associated with this session no longer exists. Stop the session to close it.") .font(.caption) .foregroundColor(.secondary) Button("Terminate Session") { _ = appData.stopChargeSession( sessionID: openChargeSession.id, finalBatteryPercent: nil ) } .frame(maxWidth: .infinity) .padding(.vertical, 9) .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 12) .buttonStyle(.plain) } .padding(14) .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16) } // Battery prediction gauge if let batteryPrediction { batteryGaugeSection( prediction: batteryPrediction, session: openChargeSession, displayedEnergyWh: displayedEnergyWh ) } // Metrics grid sessionMetricsGrid( for: openChargeSession, displayedEnergyWh: displayedEnergyWh, hasPrediction: batteryPrediction != nil ) if openChargeSession.stopThresholdAmps > 0 { Text("Stop threshold: \(openChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A") .font(.caption) .foregroundColor(.secondary) } if let sessionWarning = sessionWarning(for: openChargeSession) { Label(sessionWarning, systemImage: "exclamationmark.triangle") .font(.caption) .foregroundColor(.orange) } if openChargeSession.isPaused { Label( "Paused \(openChargeSession.pausedAt?.format() ?? openChargeSession.lastObservedAt.format()). Auto-stops after 10 min.", systemImage: "pause.circle" ) .font(.caption) .foregroundColor(.secondary) } if openChargeSession.requiresCompletionConfirmation && !showingStopConfirm { completionConfirmationCard(openChargeSession) } BatteryCheckpointSectionView( sessionID: openChargeSession.id, checkpoints: openChargeSession.checkpoints, message: "Checkpoints are used for capacity estimation and the typical charge curve.", canAddCheckpoint: canAddCheckpoint, requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: openChargeSession.id), effectiveEnergyWhOverride: displayedEnergyWh, measuredChargeAhOverride: displayedChargeAh, onDelete: { checkpoint in pendingCheckpointDeletion = checkpoint } ) targetSectionView( for: openChargeSession, predictedPercent: batteryPrediction?.predictedPercent ) if showingStopConfirm { stopConfirmPanel(for: openChargeSession) } else { HStack(spacing: 10) { if openChargeSession.status == .active { Button("Pause") { _ = appData.pauseChargeSession(sessionID: openChargeSession.id, from: usbMeter) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) } else if openChargeSession.status == .paused { Button("Resume") { _ = appData.resumeChargeSession(sessionID: openChargeSession.id, from: usbMeter) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) } Button("Terminate Session") { finalCheckpointMode = .skip finalCheckpointText = "" showingStopConfirm = true } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) } } } .padding(18) .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20) } // MARK: - Battery Gauge Section private func batteryGaugeSection( prediction: BatteryLevelPrediction, session: ChargeSessionSummary, displayedEnergyWh: Double ) -> some View { let percent = prediction.predictedPercent let color = batteryColor(for: percent) let duration = displayedSessionDuration(for: session) let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01 ? displayedEnergyWh / duration : nil let etaToFull: String? = { guard let rate = rateWhPerSec, rate > 0.0001, percent < 98 else { return nil } let remaining = max(prediction.estimatedCapacityWh - displayedEnergyWh, 0) let seconds = remaining / rate return seconds > 120 ? formatETA(seconds) : nil }() let etaToTarget: String? = { guard let target = session.targetBatteryPercent, target > percent + 1, let rate = rateWhPerSec, rate > 0.0001 else { return nil } let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh let remaining = max(targetEnergyWh - displayedEnergyWh, 0) let seconds = remaining / rate return seconds > 120 ? formatETA(seconds) : nil }() return VStack(spacing: 10) { HStack(alignment: .lastTextBaseline, spacing: 8) { HStack(alignment: .lastTextBaseline, spacing: 3) { Text("\(Int(percent.rounded()))") .font(.system(size: 52, weight: .bold, design: .rounded)) .foregroundColor(color) .monospacedDigit() Text("%") .font(.title2.weight(.semibold)) .foregroundColor(color.opacity(0.8)) } Spacer() VStack(alignment: .trailing, spacing: 2) { Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh") .font(.callout.weight(.bold)) .foregroundColor(.orange) .monospacedDigit() Text("est. capacity") .font(.caption2) .foregroundColor(.secondary) } } batteryProgressBar( percent: percent, startPercent: session.startBatteryPercent, targetPercent: session.targetBatteryPercent ) HStack(spacing: 14) { if let etaToFull { VStack(alignment: .leading, spacing: 1) { HStack(spacing: 4) { Image(systemName: "clock.fill") .font(.caption) .foregroundColor(.green) Text(etaToFull) .font(.caption.weight(.bold)) } Text("to full") .font(.caption2) .foregroundColor(.secondary) } } if let etaToTarget, let target = session.targetBatteryPercent { VStack(alignment: .leading, spacing: 1) { HStack(spacing: 4) { Image(systemName: "bell.badge.fill") .font(.caption) .foregroundColor(.indigo) Text(etaToTarget) .font(.caption.weight(.bold)) } Text("to \(Int(target.rounded()))%") .font(.caption2) .foregroundColor(.secondary) } } Spacer() Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%") .font(.caption2) .foregroundColor(.secondary) .multilineTextAlignment(.trailing) } } .padding(14) .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16) } private func batteryProgressBar( percent: Double, startPercent: Double?, targetPercent: Double? ) -> some View { let color = batteryColor(for: percent) return GeometryReader { geo in let width = geo.size.width ZStack(alignment: .leading) { Capsule() .fill(Color.primary.opacity(0.10)) Rectangle() .fill( LinearGradient( colors: [color.opacity(0.6), color], startPoint: .leading, endPoint: .trailing ) ) .frame(width: max(width * CGFloat(percent / 100), 4)) .animation(.easeInOut(duration: 0.4), value: percent) if let start = startPercent, start > 2, start < 98 { Rectangle() .fill(Color.white.opacity(0.55)) .frame(width: 2, height: 20) .offset(x: width * CGFloat(start / 100) - 1) } if let target = targetPercent { Rectangle() .fill(Color.indigo.opacity(0.9)) .frame(width: 2.5, height: 20) .offset(x: width * CGFloat(target / 100) - 1.25) } } .clipShape(Capsule()) } .frame(height: 20) } private func batteryColor(for percent: Double) -> Color { if percent >= 75 { return .green } if percent >= 35 { return .orange } return .red } private func formatETA(_ seconds: TimeInterval) -> String { let totalMinutes = Int(seconds / 60) if totalMinutes < 60 { return "\(totalMinutes)m" } let hours = totalMinutes / 60 let minutes = totalMinutes % 60 return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m" } // MARK: - Session Metrics Grid private func sessionMetricsGrid( for session: ChargeSessionSummary, displayedEnergyWh: Double, hasPrediction: Bool ) -> some View { let displayedDuration = displayedSessionDuration(for: session) let capacityFallback: Double? = hasPrediction ? nil : ( session.capacityEstimateWh ?? selectedChargedDevice?.estimatedBatteryCapacityWh(for: session.chargingTransportMode) ?? selectedChargedDevice?.estimatedBatteryCapacityWh ) let columns = [GridItem(.flexible()), GridItem(.flexible())] return LazyVGrid(columns: columns, spacing: 8) { metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue) metricCell(label: "Duration", value: formatDuration(displayedDuration), tint: .teal) if shouldShowChargingTransport(for: session) { metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange) } if shouldShowChargingState(for: session) { metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple) } metricCell(label: "Auto Stop", value: autoStopLabel(for: session), tint: .secondary) if let capacity = capacityFallback { metricCell(label: "Est. Capacity", value: "\(capacity.format(decimalDigits: 2)) Wh", tint: .orange) } } } 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 func completionConfirmationCard(_ openChargeSession: ChargeSessionSummary) -> some View { VStack(alignment: .leading, spacing: 10) { Label("Charging may have stopped", systemImage: "questionmark.circle.fill") .font(.subheadline.weight(.semibold)) if let contradictionPercent = openChargeSession.completionContradictionPercent { Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.") .font(.caption) .foregroundColor(.secondary) } else { Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.") .font(.caption) .foregroundColor(.secondary) } HStack(spacing: 10) { Button("Finish") { finalCheckpointMode = .skip finalCheckpointText = "" showingStopConfirm = true } .frame(maxWidth: .infinity) .padding(.vertical, 9) .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) Button("Keep Monitoring") { _ = appData.continueChargeSessionMonitoring(sessionID: openChargeSession.id) } .frame(maxWidth: .infinity) .padding(.vertical, 9) .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) } } .padding(14) .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16) } // MARK: - Target Section private func targetSectionView(for session: ChargeSessionSummary, predictedPercent: Double?) -> some View { let draftBelowPrediction: Bool = { guard let draft = parsedDraftTarget, let predicted = predictedPercent else { return false } return draft <= predicted }() let savedBelowPrediction: Bool = { guard let saved = session.targetBatteryPercent, let predicted = predictedPercent else { return false } return saved <= predicted }() return HStack(alignment: .center, spacing: 8) { Image(systemName: "bell.badge") .foregroundColor(.indigo) .font(.subheadline) Text("Notify at") .font(.subheadline.weight(.semibold)) Spacer(minLength: 8) if showingInlineTargetEditor { Button { let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80 let next = max(current - 1, 1) draftTargetText = next.format(decimalDigits: 0) } label: { Image(systemName: "minus.circle") .font(.title3) } .buttonStyle(.plain) TextField("—", text: $draftTargetText) .keyboardType(.decimalPad) .textFieldStyle(.roundedBorder) .frame(width: 48) .multilineTextAlignment(.center) .foregroundColor(draftBelowPrediction ? .orange : .primary) Text("%") .font(.subheadline) .foregroundColor(.secondary) if draftBelowPrediction { Button {} label: { Image(systemName: "exclamationmark.triangle.fill") .font(.body.weight(.semibold)) .foregroundColor(.orange) } .buttonStyle(.plain) .help("Battery is already predicted at \(predictedPercent!.format(decimalDigits: 0))% — this alert won't fire. Raise the value or add a checkpoint to correct the prediction.") } Button { let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80 let next = min(current + 1, 100) draftTargetText = next.format(decimalDigits: 0) } label: { Image(systemName: "plus.circle") .font(.title3) } .buttonStyle(.plain) Button { if let value = parsedDraftTarget { _ = appData.setTargetBatteryPercent(value, for: session.id) } showingInlineTargetEditor = false } label: { Image(systemName: "checkmark.circle.fill") .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary) .font(.title3) } .buttonStyle(.plain) .disabled(parsedDraftTarget == nil) Button { showingInlineTargetEditor = false draftTargetText = "" } label: { Image(systemName: "xmark.circle") .foregroundColor(.secondary) .font(.title3) } .buttonStyle(.plain) } else { if let targetPercent = session.targetBatteryPercent { Text("\(targetPercent.format(decimalDigits: 0))%") .font(.subheadline.weight(.semibold)) .foregroundColor(savedBelowPrediction ? .orange : .indigo) if savedBelowPrediction { Button {} label: { Image(systemName: "exclamationmark.triangle.fill") .font(.callout.weight(.semibold)) .foregroundColor(.orange) } .buttonStyle(.plain) .help("Battery is already predicted at \(predictedPercent!.format(decimalDigits: 0))% — this alert won't fire. Raise the value or add a checkpoint to correct the prediction.") } Button { _ = appData.setTargetBatteryPercent(nil, for: session.id) } label: { Image(systemName: "xmark.circle.fill") .foregroundColor(.secondary) .font(.callout) } .buttonStyle(.plain) .help("Remove alert") } Button { draftTargetText = session.targetBatteryPercent.map { $0.format(decimalDigits: 0) } ?? "80" showingInlineTargetEditor = true } label: { Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil") .font(.caption.weight(.semibold)) .frame(width: 30, height: 30) .contentShape(Rectangle()) } .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10) .buttonStyle(.plain) .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert") } } } private var parsedDraftTarget: Double? { let normalized = draftTargetText .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: ",", with: ".") guard let value = Double(normalized), value >= 1, value <= 100 else { return nil } return value } private func stopConfirmPanel(for session: ChargeSessionSummary) -> some View { VStack(alignment: .leading, spacing: 12) { Text("Final Checkpoint (optional)") .font(.subheadline.weight(.semibold)) HStack(spacing: 8) { ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in Button { finalCheckpointMode = mode if mode != .custom { finalCheckpointText = "" } } label: { VStack(spacing: 5) { Image(systemName: mode.icon) .font(.title3) .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary) Text(mode.label) .font(.caption.weight(.semibold)) .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .background(finalCheckpointMode == mode ? Color.primary.opacity(0.10) : Color.clear) .meterCard( tint: finalCheckpointMode == mode ? .primary : .secondary, fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04, strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10, cornerRadius: 12 ) } .buttonStyle(.plain) } } if finalCheckpointMode == .custom { HStack(spacing: 8) { Button { adjustFinalCheckpoint(by: -1) } label: { Image(systemName: "minus.circle").font(.title3) } .buttonStyle(.plain) TextField("—", text: $finalCheckpointText) .keyboardType(.decimalPad) .textFieldStyle(.roundedBorder) .frame(width: 56) .multilineTextAlignment(.center) Text("%").foregroundColor(.secondary) Button { adjustFinalCheckpoint(by: 1) } label: { Image(systemName: "plus.circle").font(.title3) } .buttonStyle(.plain) Spacer() } } HStack(spacing: 8) { Button("Discard") { _ = appData.deleteChargeSession(sessionID: session.id) showingStopConfirm = false finalCheckpointText = "" finalCheckpointMode = .full } .frame(maxWidth: .infinity) .padding(.vertical, 9) .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14) .buttonStyle(.plain) let saveDisabled = finalCheckpointMode == .custom && finalCheckpointText.isEmpty == false && parsedFinalCheckpoint == nil Button("Save") { _ = appData.stopChargeSession( sessionID: session.id, finalBatteryPercent: resolvedFinalCheckpoint ) showingStopConfirm = false finalCheckpointText = "" finalCheckpointMode = .full } .frame(maxWidth: .infinity) .padding(.vertical, 9) .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) .disabled(saveDisabled) Button("Cancel") { showingStopConfirm = false finalCheckpointText = "" finalCheckpointMode = .full } .frame(maxWidth: .infinity) .padding(.vertical, 9) .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14) .buttonStyle(.plain) } } .padding(14) .meterCard(tint: .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16) } private var parsedFinalCheckpoint: Double? { let normalized = finalCheckpointText .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: ",", with: ".") guard let value = Double(normalized), value >= 0, value <= 100 else { return nil } return value } private var resolvedFinalCheckpoint: Double? { switch finalCheckpointMode { case .full: return 100.0 case .skip: return nil case .custom: return parsedFinalCheckpoint } } private func adjustFinalCheckpoint(by delta: Double) { let current = parsedFinalCheckpoint ?? 0 let next = min(max(current + delta, 0), 100) finalCheckpointText = next.format(decimalDigits: 0) } // MARK: - Trim Detection Banner @ViewBuilder private func trimDetectionBanner(for session: ChargeSessionSummary) -> some View { if let window = detectedTrimWindow { HStack(spacing: 12) { Image(systemName: "scissors.circle.fill") .font(.title3) .foregroundColor(.blue) VStack(alignment: .leading, spacing: 2) { Text("Charging ended early") .font(.subheadline.weight(.semibold)) Text("Active charging detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")). The rest may be standby or another device.") .font(.caption) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 0) VStack(spacing: 6) { Button("Apply") { _ = appData.setSessionTrim( sessionID: session.id, start: window.start, end: window.end ) trimBannerDismissedForSessionID = session.id } .font(.caption.weight(.semibold)) .buttonStyle(.borderedProminent) .controlSize(.small) .tint(.blue) Button { trimBannerDismissedForSessionID = session.id } label: { Image(systemName: "xmark") .font(.caption2.weight(.semibold)) .foregroundColor(.secondary) } .buttonStyle(.plain) } } .padding(14) .background( RoundedRectangle(cornerRadius: 14) .fill(Color.blue.opacity(0.10)) .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1)) ) .transition(.opacity.combined(with: .move(edge: .top))) } } private func sessionChartCard(timeRange: ClosedRange?, session: ChargeSessionSummary) -> some View { let hasRangeSelector = session.aggregatedSamples.isEmpty == false return VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { Image(systemName: "chart.xyaxis.line") .foregroundColor(.blue) Text("Session Chart") .font(.headline) ContextInfoButton( title: "Session Chart", message: usesChargeRecordBuffer(for: session) ? "This chart combines the persisted session curve with current live data from this meter." : "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging." ) Spacer(minLength: 0) } MeasurementChartView( timeRange: timeRange, timeRangeLowerBound: sessionChartLiveTrimBounds(for: session).lower, timeRangeUpperBound: sessionChartLiveTrimBounds(for: session).upper, showsRangeSelector: hasRangeSelector, rebasesEnergyToVisibleRangeStart: true, extendsTimelineToPresent: false, rangeSelectorConfiguration: hasRangeSelector ? MeasurementChartRangeSelectorConfiguration( keepAction: MeasurementChartSelectionAction( title: "Keep Selection", shortTitle: "Keep", systemName: "scissors", tone: .destructive, handler: { range in _ = appData.setSessionTrim( sessionID: session.id, start: range.lowerBound, end: range.upperBound ) trimBannerDismissedForSessionID = session.id } ), removeAction: nil, resetAction: MeasurementChartResetAction( title: "Reset Trim", shortTitle: "Reset", systemName: "arrow.counterclockwise", tone: .reversible, confirmationTitle: "Reset session trim?", confirmationButtonTitle: "Reset trim", handler: { _ = appData.setSessionTrim(sessionID: session.id, start: nil, end: nil) } ) ) : nil ) .environmentObject(usbMeter.chargeRecordMeasurements) .frame(maxWidth: .infinity, alignment: .topLeading) } .padding(18) .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20) } 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 autoStopLabel(for session: ChargeSessionSummary) -> String { if session.autoStopEnabled == false { return "Manual" } if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless { return sessionWarning.contains("idle-current") ? "Blocked" : "Manual" } if session.stopThresholdAmps > 0 { return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A" } return "Learning" } private func sessionMetricRows( for session: ChargeSessionSummary, displayedEnergyWh: Double ) -> [SessionMetricRow] { var rows: [SessionMetricRow] = [] if shouldShowChargingTransport(for: session) { rows.append(SessionMetricRow(label: "Type", value: session.chargingTransportMode.title)) } if shouldShowChargingState(for: session) { rows.append(SessionMetricRow(label: "Mode", value: session.chargingStateMode.title)) } rows.append(SessionMetricRow(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh")) rows.append(SessionMetricRow(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)))) rows.append(SessionMetricRow(label: "Auto Stop", value: autoStopLabel(for: session))) return rows } private func shouldShowChargingTransport(for session: ChargeSessionSummary) -> Bool { guard let selectedChargedDevice else { return true } return selectedChargedDevice.supportedChargingModes.count > 1 || selectedChargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false } private func shouldShowChargingState(for session: ChargeSessionSummary) -> Bool { guard let selectedChargedDevice else { return true } return selectedChargedDevice.supportedChargingStateModes.count > 1 || selectedChargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false } private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double { let storedEnergyWh = session.effectiveOrMeasuredEnergyWh guard session.isTrimmed == false else { return storedEnergyWh } guard session.status.isOpen else { return storedEnergyWh } guard session.meterMACAddress == meterMACAddress else { return storedEnergyWh } if let baselineEnergyWh = session.meterEnergyBaselineWh { return max(storedEnergyWh, max(usbMeter.recordedWH - baselineEnergyWh, 0)) } return storedEnergyWh } private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double { let storedChargeAh = session.measuredChargeAh guard session.isTrimmed == false else { return storedChargeAh } guard session.status.isOpen else { return storedChargeAh } guard session.meterMACAddress == meterMACAddress else { return storedChargeAh } if let baselineChargeAh = session.meterChargeBaselineAh { return max(storedChargeAh, max(usbMeter.recordedAH - baselineChargeAh, 0)) } return storedChargeAh } private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval { let storedDuration = max(session.effectiveDuration, 0) guard session.isTrimmed == false else { return storedDuration } guard session.status.isOpen else { return storedDuration } guard session.meterMACAddress == meterMACAddress else { return storedDuration } return max(storedDuration, max(usbMeter.chargeRecordDuration, 0)) } private func formatDuration(_ duration: TimeInterval) -> String { let totalSeconds = Int(duration.rounded(.down)) let hours = totalSeconds / 3600 let minutes = (totalSeconds % 3600) / 60 let seconds = totalSeconds % 60 if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) } return String(format: "%02d:%02d", minutes, seconds) } private func sessionWarning(for session: ChargeSessionSummary) -> String? { guard session.chargingTransportMode == .wireless, let chargerID = session.chargerID, let charger = appData.chargedDeviceSummary(id: chargerID) else { return nil } guard charger.chargerIdleCurrentAmps == nil else { return nil } return "The selected charger has no idle-current measurement. Wireless stop-threshold learning and precise auto-stop are unavailable for this session." } private func startSession() { guard let selectedChargedDevice, let chargingTransportMode = selectedDraftTransportMode, let chargingStateMode = selectedDraftChargingStateMode else { return } let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil let didStart = appData.startChargeSession( for: usbMeter, chargedDeviceID: selectedChargedDevice.id, chargerID: chargerID, chargingTransportMode: chargingTransportMode, chargingStateMode: chargingStateMode, autoStopEnabled: false, initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil, startsFromFlatBattery: initialCheckpointMode == .flat ) if didStart { initialCheckpoint = "" initialCheckpointMode = .known } } 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() { 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) } }