diff --git a/Gaze/Services/CalibrationSampleCollector.swift b/Gaze/Services/CalibrationSampleCollector.swift deleted file mode 100644 index b38ab11..0000000 --- a/Gaze/Services/CalibrationSampleCollector.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// CalibrationSampleCollector.swift -// Gaze -// -// Created by Mike Freno on 1/29/26. -// - -import Foundation - -struct CalibrationSampleCollector { - mutating func addSample( - to data: inout CalibrationData, - step: CalibrationStep, - leftRatio: Double?, - rightRatio: Double?, - leftVertical: Double?, - rightVertical: Double?, - faceWidthRatio: Double? - ) { - let sample = GazeSample( - leftRatio: leftRatio, - rightRatio: rightRatio, - leftVerticalRatio: leftVertical, - rightVerticalRatio: rightVertical, - faceWidthRatio: faceWidthRatio - ) - data.addSample(sample, for: step) - } -} diff --git a/Gaze/Services/CalibrationWindowManager.swift b/Gaze/Services/CalibrationWindowManager.swift deleted file mode 100644 index 77353ef..0000000 --- a/Gaze/Services/CalibrationWindowManager.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// CalibrationWindowManager.swift -// Gaze -// -// Manages the fullscreen calibration overlay window. -// - -import AppKit -import SwiftUI - -@MainActor -final class CalibrationWindowManager { - static let shared = CalibrationWindowManager() - - private var windowController: NSWindowController? - - private init() {} - - func showCalibrationOverlay() { - guard let screen = NSScreen.main else { return } - - let window = KeyableWindow( - contentRect: screen.frame, - styleMask: [.borderless, .fullSizeContentView], - backing: .buffered, - defer: false - ) - - window.level = .screenSaver - window.isOpaque = true - window.backgroundColor = .black - window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - window.acceptsMouseMovedEvents = true - window.ignoresMouseEvents = false - - let overlayView = CalibrationOverlayView { - self.dismissCalibrationOverlay() - } - window.contentView = NSHostingView(rootView: overlayView) - - windowController = NSWindowController(window: window) - windowController?.showWindow(nil) - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - - print("đŸŽ¯ Calibration overlay window opened") - } - - func dismissCalibrationOverlay() { - windowController?.close() - windowController = nil - print("đŸŽ¯ Calibration overlay window closed") - } -} diff --git a/Gaze/Services/CalibrationManager.swift b/Gaze/Services/CalibratorService.swift similarity index 73% rename from Gaze/Services/CalibrationManager.swift rename to Gaze/Services/CalibratorService.swift index f78323d..0bc1a89 100644 --- a/Gaze/Services/CalibrationManager.swift +++ b/Gaze/Services/CalibratorService.swift @@ -1,52 +1,45 @@ // -// CalibrationManager.swift +// CalibratorService.swift // Gaze // -// Created by Mike Freno on 1/15/26. +// Created by Mike Freno on 1/29/26. // import Combine import Foundation +import AppKit +import SwiftUI @MainActor -class CalibrationManager: ObservableObject { - static let shared = CalibrationManager() - - // MARK: - Published Properties - +final class CalibratorService: ObservableObject { + static let shared = CalibratorService() + @Published var isCalibrating = false - @Published var isCollectingSamples = false // True when actively collecting (after countdown) + @Published var isCollectingSamples = false @Published var currentStep: CalibrationStep? @Published var currentStepIndex = 0 @Published var samplesCollected = 0 @Published var calibrationData = CalibrationData() - - // MARK: - Configuration - private let samplesPerStep = 30 // Collect 30 samples per calibration point (~1 second at 30fps) + private let samplesPerStep = 30 private let userDefaultsKey = "eyeTrackingCalibration" - - private let calibrationSteps: [CalibrationStep] = [ - .center, - .left, - .right, - .farLeft, - .farRight, - .up, - .down, - .topLeft, - .topRight - ] - private let flowController: CalibrationFlowController - private var sampleCollector = CalibrationSampleCollector() - - // MARK: - Initialization - + private var windowController: NSWindowController? + private init() { self.flowController = CalibrationFlowController( samplesPerStep: samplesPerStep, - calibrationSteps: calibrationSteps + calibrationSteps: [ + .center, + .left, + .right, + .farLeft, + .farRight, + .up, + .down, + .topLeft, + .topRight + ] ) loadCalibration() bindFlowController() @@ -62,29 +55,26 @@ class CalibrationManager: ObservableObject { flowController.$samplesCollected .assign(to: &$samplesCollected) } - - // MARK: - Calibration Flow - + func startCalibration() { print("đŸŽ¯ Starting calibration...") isCalibrating = true flowController.start() calibrationData = CalibrationData() } - - /// Reset state for a new calibration attempt (clears isComplete flag from previous calibration) + func resetForNewCalibration() { print("🔄 Resetting for new calibration...") calibrationData = CalibrationData() flowController.start() } - + func startCollectingSamples() { guard isCalibrating else { return } print("📊 Started collecting samples for step: \(currentStep?.displayName ?? "unknown")") flowController.startCollectingSamples() } - + func collectSample( leftRatio: Double?, rightRatio: Double?, @@ -94,21 +84,20 @@ class CalibrationManager: ObservableObject { ) { guard isCalibrating, isCollectingSamples, let step = currentStep else { return } - sampleCollector.addSample( - to: &calibrationData, - step: step, + let sample = GazeSample( leftRatio: leftRatio, rightRatio: rightRatio, - leftVertical: leftVertical, - rightVertical: rightVertical, + leftVerticalRatio: leftVertical, + rightVerticalRatio: rightVertical, faceWidthRatio: faceWidthRatio ) + calibrationData.addSample(sample, for: step) if flowController.markSampleCollected() { advanceToNextStep() } } - + private func advanceToNextStep() { if flowController.advanceToNextStep() { print("📍 Calibration step: \(currentStep?.displayName ?? "unknown")") @@ -116,42 +105,75 @@ class CalibrationManager: ObservableObject { finishCalibration() } } - + func skipStep() { - // Allow skipping optional steps (up, down, diagonals) guard isCalibrating, let step = currentStep else { return } - + print("â­ī¸ Skipping calibration step: \(step.displayName)") advanceToNextStep() } - + + func showCalibrationOverlay() { + guard let screen = NSScreen.main else { return } + + let window = KeyableWindow( + contentRect: screen.frame, + styleMask: [.borderless, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + window.level = .screenSaver + window.isOpaque = true + window.backgroundColor = .black + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.acceptsMouseMovedEvents = true + window.ignoresMouseEvents = false + + let overlayView = CalibrationOverlayView { + self.dismissCalibrationOverlay() + } + window.contentView = NSHostingView(rootView: overlayView) + + windowController = NSWindowController(window: window) + windowController?.showWindow(nil) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + print("đŸŽ¯ Calibration overlay window opened") + } + + func dismissCalibrationOverlay() { + windowController?.close() + windowController = nil + print("đŸŽ¯ Calibration overlay window closed") + } + func finishCalibration() { print("✓ Calibration complete, calculating thresholds...") - + calibrationData.calculateThresholds() calibrationData.isComplete = true calibrationData.calibrationDate = Date() - + saveCalibration() applyCalibration() - + isCalibrating = false flowController.stop() - + print("✓ Calibration saved and applied") } - + func cancelCalibration() { print("❌ Calibration cancelled") isCalibrating = false flowController.stop() calibrationData = CalibrationData() - + CalibrationState.shared.reset() } - - // MARK: - Persistence - + private func saveCalibration() { do { let encoder = JSONEncoder() @@ -163,18 +185,18 @@ class CalibrationManager: ObservableObject { print("❌ Failed to save calibration: \(error)") } } - + func loadCalibration() { guard let data = UserDefaults.standard.data(forKey: userDefaultsKey) else { print("â„šī¸ No existing calibration found") return } - + do { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 calibrationData = try decoder.decode(CalibrationData.self, from: data) - + if isCalibrationValid() { print("✓ Loaded valid calibration from \(calibrationData.calibrationDate)") applyCalibration() @@ -185,18 +207,14 @@ class CalibrationManager: ObservableObject { print("❌ Failed to load calibration: \(error)") } } - + func clearCalibration() { UserDefaults.standard.removeObject(forKey: userDefaultsKey) calibrationData = CalibrationData() - CalibrationState.shared.reset() - print("đŸ—‘ī¸ Calibration data cleared") } - - // MARK: - Validation - + func isCalibrationValid() -> Bool { guard calibrationData.isComplete, let thresholds = calibrationData.computedThresholds, @@ -205,23 +223,20 @@ class CalibrationManager: ObservableObject { } return true } - + func needsRecalibration() -> Bool { return !isCalibrationValid() } - - // MARK: - Apply Calibration - + private func applyCalibration() { guard let thresholds = calibrationData.computedThresholds else { print("âš ī¸ No thresholds to apply") return } - - // Push to thread-safe state for background processing + CalibrationState.shared.setThresholds(thresholds) CalibrationState.shared.setComplete(true) - + print("✓ Applied calibrated thresholds:") print(" Looking left: â‰Ĩ\(String(format: "%.3f", thresholds.minLeftRatio))") print(" Looking right: ≤\(String(format: "%.3f", thresholds.maxRightRatio))") @@ -229,36 +244,50 @@ class CalibrationManager: ObservableObject { print(" Looking down: â‰Ĩ\(String(format: "%.3f", thresholds.maxDownRatio))") print(" Screen Bounds: [\(String(format: "%.2f", thresholds.screenRightBound))..\(String(format: "%.2f", thresholds.screenLeftBound))] x [\(String(format: "%.2f", thresholds.screenTopBound))..\(String(format: "%.2f", thresholds.screenBottomBound))]") } - - // MARK: - Statistics - + func getCalibrationSummary() -> String { guard calibrationData.isComplete else { return "No calibration data" } - + let dateFormatter = DateFormatter() dateFormatter.dateStyle = .medium dateFormatter.timeStyle = .short - + var summary = "Calibrated: \(dateFormatter.string(from: calibrationData.calibrationDate))\n" - + if let thresholds = calibrationData.computedThresholds { summary += "H-Range: \(String(format: "%.3f", thresholds.screenRightBound)) to \(String(format: "%.3f", thresholds.screenLeftBound))\n" summary += "V-Range: \(String(format: "%.3f", thresholds.screenTopBound)) to \(String(format: "%.3f", thresholds.screenBottomBound))\n" summary += "Ref Face Width: \(String(format: "%.3f", thresholds.referenceFaceWidth))" } - + return summary } - - // MARK: - Progress - + var progress: Double { flowController.progress } - + var progressText: String { flowController.progressText } + + func submitSampleToBridge( + leftRatio: Double, + rightRatio: Double, + leftVertical: Double? = nil, + rightVertical: Double? = nil, + faceWidthRatio: Double = 0 + ) { + Task { @MainActor in + collectSample( + leftRatio: leftRatio, + rightRatio: rightRatio, + leftVertical: leftVertical, + rightVertical: rightVertical, + faceWidthRatio: faceWidthRatio + ) + } + } } diff --git a/Gaze/Services/EyeTracking/CalibrationBridge.swift b/Gaze/Services/EyeTracking/CalibrationBridge.swift deleted file mode 100644 index ad9657f..0000000 --- a/Gaze/Services/EyeTracking/CalibrationBridge.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// CalibrationBridge.swift -// Gaze -// -// Thread-safe calibration access for eye tracking. -// - -import Foundation - -final class CalibrationBridge: @unchecked Sendable { - nonisolated var thresholds: GazeThresholds? { - CalibrationState.shared.thresholds - } - - nonisolated var isComplete: Bool { - CalibrationState.shared.isComplete - } - - nonisolated func submitSample( - leftRatio: Double, - rightRatio: Double, - leftVertical: Double?, - rightVertical: Double?, - faceWidthRatio: Double - ) { - Task { @MainActor in - CalibrationManager.shared.collectSample( - leftRatio: leftRatio, - rightRatio: rightRatio, - leftVertical: leftVertical, - rightVertical: rightVertical, - faceWidthRatio: faceWidthRatio - ) - } - } -} diff --git a/Gaze/Services/EyeTrackingService.swift b/Gaze/Services/EyeTrackingService.swift index 4e0ef95..d7fcdff 100644 --- a/Gaze/Services/EyeTrackingService.swift +++ b/Gaze/Services/EyeTrackingService.swift @@ -44,7 +44,6 @@ class EyeTrackingService: NSObject, ObservableObject { private let cameraManager = CameraSessionManager() private let visionPipeline = VisionPipeline() private let debugAdapter = EyeDebugStateAdapter() - private let calibrationBridge = CalibrationBridge() private let gazeDetector: GazeDetector var previewLayer: AVCaptureVideoPreviewLayer? { @@ -135,10 +134,11 @@ class EyeTrackingService: NSObject, ObservableObject { debugImageSize = debugAdapter.imageSize } - nonisolated private func updateGazeConfiguration() { + @MainActor + private func updateGazeConfiguration() { let configuration = GazeDetector.Configuration( - thresholds: calibrationBridge.thresholds, - isCalibrationComplete: calibrationBridge.isComplete, + thresholds: CalibrationState.shared.thresholds, + isCalibrationComplete: CalibratorService.shared.isCalibrating || CalibrationState.shared.isComplete, eyeClosedEnabled: EyeTrackingConstants.eyeClosedEnabled, eyeClosedThreshold: EyeTrackingConstants.eyeClosedThreshold, yawEnabled: EyeTrackingConstants.yawEnabled, @@ -173,8 +173,8 @@ extension EyeTrackingService: CameraSessionDelegate { let rightRatio = result.rightPupilRatio, let faceWidth = result.faceWidthRatio { Task { @MainActor in - guard CalibrationManager.shared.isCalibrating else { return } - calibrationBridge.submitSample( + guard CalibratorService.shared.isCalibrating else { return } + CalibratorService.shared.submitSampleToBridge( leftRatio: leftRatio, rightRatio: rightRatio, leftVertical: result.leftVerticalRatio, diff --git a/Gaze/Views/Components/CalibrationOverlayView.swift b/Gaze/Views/Components/CalibrationOverlayView.swift index 5651fa9..090ea6e 100644 --- a/Gaze/Views/Components/CalibrationOverlayView.swift +++ b/Gaze/Views/Components/CalibrationOverlayView.swift @@ -10,7 +10,7 @@ import Combine import SwiftUI struct CalibrationOverlayView: View { - @StateObject private var calibrationManager = CalibrationManager.shared + @StateObject private var calibratorService = CalibratorService.shared @StateObject private var eyeTrackingService = EyeTrackingService.shared @StateObject private var viewModel = CalibrationOverlayViewModel() @@ -33,10 +33,10 @@ struct CalibrationOverlayView: View { errorView(error) } else if !viewModel.cameraStarted { startingCameraView - } else if calibrationManager.isCalibrating { + } else if calibratorService.isCalibrating { calibrationContentView(screenSize: geometry.size) } else if viewModel.calibrationStarted - && calibrationManager.calibrationData.isComplete + && calibratorService.calibrationData.isComplete { // Only show completion if we started calibration this session AND it completed completionView @@ -48,15 +48,15 @@ struct CalibrationOverlayView: View { } .task { await viewModel.startCamera( - eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager) + eyeTrackingService: eyeTrackingService, calibratorService: calibratorService) } .onDisappear { viewModel.cleanup( - eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager) + eyeTrackingService: eyeTrackingService, calibratorService: calibratorService) } - .onChange(of: calibrationManager.currentStep) { oldStep, newStep in + .onChange(of: calibratorService.currentStep) { oldStep, newStep in if newStep != nil && oldStep != newStep { - viewModel.startStepCountdown(calibrationManager: calibrationManager) + viewModel.startStepCountdown(calibratorService: calibratorService) } } } @@ -110,7 +110,7 @@ struct CalibrationOverlayView: View { Spacer() } - if let step = calibrationManager.currentStep { + if let step = calibratorService.currentStep { calibrationTarget(for: step, screenSize: screenSize) } @@ -119,7 +119,7 @@ struct CalibrationOverlayView: View { HStack { cancelButton Spacer() - if !calibrationManager.isCollectingSamples { + if !calibratorService.isCollectingSamples { skipButton } } @@ -146,11 +146,11 @@ struct CalibrationOverlayView: View { Text("Calibrating...") .foregroundStyle(.white) Spacer() - Text(calibrationManager.progressText) + Text(calibratorService.progressText) .foregroundStyle(.white.opacity(0.7)) } - ProgressView(value: calibrationManager.progress) + ProgressView(value: calibratorService.progress) .progressViewStyle(.linear) .tint(.blue) } @@ -198,29 +198,29 @@ struct CalibrationOverlayView: View { value: viewModel.isCountingDown) // Progress ring when collecting - if calibrationManager.isCollectingSamples { + if calibratorService.isCollectingSamples { Circle() - .trim(from: 0, to: CGFloat(calibrationManager.samplesCollected) / 30.0) + .trim(from: 0, to: CGFloat(calibratorService.samplesCollected) / 30.0) .stroke(Color.green, lineWidth: 4) .frame(width: 90, height: 90) .rotationEffect(.degrees(-90)) .animation( - .linear(duration: 0.1), value: calibrationManager.samplesCollected) + .linear(duration: 0.1), value: calibratorService.samplesCollected) } // Inner circle Circle() - .fill(calibrationManager.isCollectingSamples ? Color.green : Color.blue) + .fill(calibratorService.isCollectingSamples ? Color.green : Color.blue) .frame(width: 60, height: 60) .animation( - .easeInOut(duration: 0.3), value: calibrationManager.isCollectingSamples) + .easeInOut(duration: 0.3), value: calibratorService.isCollectingSamples) // Countdown number or collecting indicator if viewModel.isCountingDown && viewModel.countdownValue > 0 { Text("\(viewModel.countdownValue)") .font(.system(size: 36, weight: .bold)) .foregroundStyle(.white) - } else if calibrationManager.isCollectingSamples { + } else if calibratorService.isCollectingSamples { Image(systemName: "eye.fill") .font(.system(size: 24, weight: .bold)) .foregroundStyle(.white) @@ -241,7 +241,7 @@ struct CalibrationOverlayView: View { private func instructionText(for step: CalibrationStep) -> String { if viewModel.isCountingDown && viewModel.countdownValue > 0 { return "Get ready..." - } else if calibrationManager.isCollectingSamples { + } else if calibratorService.isCollectingSamples { return "Look at the target" } else { return step.instructionText @@ -252,7 +252,7 @@ struct CalibrationOverlayView: View { private var skipButton: some View { Button { - viewModel.skipCurrentStep(calibrationManager: calibrationManager) + viewModel.skipCurrentStep(calibratorService: calibratorService) } label: { Text("Skip") .foregroundStyle(.white) @@ -267,7 +267,7 @@ struct CalibrationOverlayView: View { private var cancelButton: some View { Button { viewModel.cleanup( - eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager) + eyeTrackingService: eyeTrackingService, calibratorService: calibratorService) onDismiss() } label: { HStack(spacing: 6) { @@ -369,7 +369,7 @@ class CalibrationOverlayViewModel: ObservableObject { private var lastFaceDetectedTime: Date = .distantPast private let faceDetectionDebounce: TimeInterval = 0.5 // 500ms debounce - func startCamera(eyeTrackingService: EyeTrackingService, calibrationManager: CalibrationManager) + func startCamera(eyeTrackingService: EyeTrackingService, calibratorService: CalibratorService) async { do { @@ -382,10 +382,10 @@ class CalibrationOverlayViewModel: ObservableObject { try? await Task.sleep(for: .seconds(0.5)) // Reset any previous calibration data before starting fresh - calibrationManager.resetForNewCalibration() - calibrationManager.startCalibration() + calibratorService.resetForNewCalibration() + calibratorService.startCalibration() calibrationStarted = true - startStepCountdown(calibrationManager: calibrationManager) + startStepCountdown(calibratorService: calibratorService) } catch { showError = "Failed to start camera: \(error.localizedDescription)" } @@ -411,28 +411,28 @@ class CalibrationOverlayViewModel: ObservableObject { } } - func cleanup(eyeTrackingService: EyeTrackingService, calibrationManager: CalibrationManager) { + func cleanup(eyeTrackingService: EyeTrackingService, calibratorService: CalibratorService) { countdownTask?.cancel() countdownTask = nil faceDetectionCancellable?.cancel() faceDetectionCancellable = nil isCountingDown = false - if calibrationManager.isCalibrating { - calibrationManager.cancelCalibration() + if calibratorService.isCalibrating { + calibratorService.cancelCalibration() } eyeTrackingService.stopEyeTracking() } - func skipCurrentStep(calibrationManager: CalibrationManager) { + func skipCurrentStep(calibratorService: CalibratorService) { countdownTask?.cancel() countdownTask = nil isCountingDown = false - calibrationManager.skipStep() + calibratorService.skipStep() } - func startStepCountdown(calibrationManager: CalibrationManager) { + func startStepCountdown(calibratorService: CalibratorService) { countdownTask?.cancel() countdownTask = nil countdownValue = 1 @@ -446,7 +446,7 @@ class CalibrationOverlayViewModel: ObservableObject { // Done counting, start collecting isCountingDown = false countdownValue = 0 - calibrationManager.startCollectingSamples() + calibratorService.startCollectingSamples() } } } diff --git a/Gaze/Views/Components/EyeTrackingCalibrationView.swift b/Gaze/Views/Components/EyeTrackingCalibrationView.swift index 40ac3a4..dd05830 100644 --- a/Gaze/Views/Components/EyeTrackingCalibrationView.swift +++ b/Gaze/Views/Components/EyeTrackingCalibrationView.swift @@ -8,7 +8,7 @@ import SwiftUI struct EyeTrackingCalibrationView: View { - @StateObject private var calibrationManager = CalibrationManager.shared + @StateObject private var calibratorService = CalibratorService.shared @Environment(\.dismiss) private var dismiss var body: some View { @@ -46,12 +46,12 @@ struct EyeTrackingCalibrationView: View { } .padding(.vertical, 20) - if calibrationManager.calibrationData.isComplete { + if calibratorService.calibrationData.isComplete { VStack(spacing: 10) { Text("Last calibration:") .font(.caption) .foregroundStyle(.gray) - Text(calibrationManager.getCalibrationSummary()) + Text(calibratorService.getCalibrationSummary()) .font(.caption) .multilineTextAlignment(.center) .foregroundStyle(.gray) @@ -83,10 +83,10 @@ struct EyeTrackingCalibrationView: View { private func startFullscreenCalibration() { dismiss() - + // Small delay to allow sheet dismissal animation DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - CalibrationWindowManager.shared.showCalibrationOverlay() + CalibratorService.shared.showCalibrationOverlay() } } } diff --git a/Gaze/Views/Setup/EnforceModeSetupView.swift b/Gaze/Views/Setup/EnforceModeSetupView.swift index 6f14631..8d9828a 100644 --- a/Gaze/Views/Setup/EnforceModeSetupView.swift +++ b/Gaze/Views/Setup/EnforceModeSetupView.swift @@ -23,7 +23,7 @@ struct EnforceModeSetupView: View { @State private var isViewActive = false @State private var showAdvancedSettings = false @State private var showCalibrationWindow = false - @ObservedObject var calibrationManager = CalibrationManager.shared + @ObservedObject var calibratorService = CalibratorService.shared private var cameraHardwareAvailable: Bool { cameraService.hasCameraHardware @@ -155,13 +155,13 @@ struct EnforceModeSetupView: View { .font(.headline) } - if calibrationManager.calibrationData.isComplete { + if calibratorService.calibrationData.isComplete { VStack(alignment: .leading, spacing: 8) { - Text(calibrationManager.getCalibrationSummary()) + Text(calibratorService.getCalibrationSummary()) .font(.caption) .foregroundStyle(.secondary) - if calibrationManager.needsRecalibration() { + if calibratorService.needsRecalibration() { Label( "Calibration expired - recalibration recommended", systemImage: "exclamationmark.triangle.fill" @@ -186,7 +186,7 @@ struct EnforceModeSetupView: View { HStack { Image(systemName: "target") Text( - calibrationManager.calibrationData.isComplete + calibratorService.calibrationData.isComplete ? "Recalibrate" : "Run Calibration") } .frame(maxWidth: .infinity)