diff --git a/Gaze/Models/SetupPresentation.swift b/Gaze/Models/SetupPresentation.swift new file mode 100644 index 0000000..d9cef97 --- /dev/null +++ b/Gaze/Models/SetupPresentation.swift @@ -0,0 +1,16 @@ +// +// SetupPresentation.swift +// Gaze +// +// Created by Mike Freno on 1/30/26. +// + +import Foundation + +enum SetupPresentation { + case window + case card + + var isWindow: Bool { self == .window } + var isCard: Bool { self == .card } +} diff --git a/Gaze/Views/Components/EnforceModeSetupContent.swift b/Gaze/Views/Components/EnforceModeSetupContent.swift new file mode 100644 index 0000000..a9e1bee --- /dev/null +++ b/Gaze/Views/Components/EnforceModeSetupContent.swift @@ -0,0 +1,552 @@ +// +// EnforceModeSetupContent.swift +// Gaze +// +// Created by Mike Freno on 1/30/26. +// + +import AVFoundation +import SwiftUI + +struct EnforceModeSetupContent: View { + @Bindable var settingsManager: SettingsManager + @ObservedObject var cameraService = CameraAccessService.shared + @ObservedObject var eyeTrackingService = EyeTrackingService.shared + @ObservedObject var enforceModeService = EnforceModeService.shared + @ObservedObject var calibratorService = CalibratorService.shared + @Environment(\.isCompactLayout) private var isCompact + + let presentation: SetupPresentation + @Binding var isTestModeActive: Bool + @Binding var cachedPreviewLayer: AVCaptureVideoPreviewLayer? + @Binding var showAdvancedSettings: Bool + @Binding var showCalibrationWindow: Bool + @Binding var isViewActive: Bool + let isProcessingToggle: Bool + let handleEnforceModeToggle: (Bool) -> Void + + private var cameraHardwareAvailable: Bool { + cameraService.hasCameraHardware + } + + private var sectionCornerRadius: CGFloat { + presentation.isCard ? 10 : 12 + } + + private var sectionPadding: CGFloat { + presentation.isCard ? 10 : 16 + } + + private var headerFont: Font { + presentation.isCard ? .subheadline : .headline + } + + private var iconSize: CGFloat { + presentation.isCard ? AdaptiveLayout.Font.cardIconSmall : AdaptiveLayout.Font.cardIcon + } + + var body: some View { + VStack(spacing: presentation.isCard ? 10 : 24) { + if presentation.isCard { + Image(systemName: "video.fill") + .font(.system(size: iconSize)) + .foregroundStyle(Color.accentColor) + + Text("Enforce Mode") + .font(.title2) + .fontWeight(.bold) + } + + Text("Use your camera to ensure you take breaks") + .font(presentation.isCard ? .subheadline : (isCompact ? .subheadline : .title3)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + if presentation.isCard { + Spacer(minLength: 0) + } + + VStack(spacing: presentation.isCard ? 10 : 20) { + enforceModeToggleView + cameraStatusView + if enforceModeService.isEnforceModeEnabled { + testModeButton + } + if isTestModeActive && enforceModeService.isCameraActive { + testModePreviewView + trackingConstantsView + } else if enforceModeService.isCameraActive && !isTestModeActive { + eyeTrackingStatusView + trackingConstantsView + } + privacyInfoView + } + + if presentation.isCard { + Spacer(minLength: 0) + } + } + .sheet(isPresented: $showCalibrationWindow) { + EyeTrackingCalibrationView() + } + } + + private var testModeButton: some View { + Button(action: { + Task { @MainActor in + if isTestModeActive { + enforceModeService.stopTestMode() + isTestModeActive = false + cachedPreviewLayer = nil + } else { + await enforceModeService.startTestMode() + isTestModeActive = enforceModeService.isCameraActive + if isTestModeActive { + cachedPreviewLayer = eyeTrackingService.previewLayer + } + } + } + }) { + HStack { + Image(systemName: isTestModeActive ? "stop.circle.fill" : "play.circle.fill") + .font(.title3) + Text(isTestModeActive ? "Stop Test" : "Test Tracking") + .font(.headline) + } + .frame(maxWidth: .infinity) + .padding() + } + .buttonStyle(.borderedProminent) + .controlSize(presentation.isCard ? .regular : .large) + } + + private var calibrationSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "target") + .font(.title3) + .foregroundStyle(.blue) + Text("Eye Tracking Calibration") + .font(.headline) + } + + if calibratorService.calibrationData.isComplete { + VStack(alignment: .leading, spacing: 8) { + Text(calibratorService.getCalibrationSummary()) + .font(.caption) + .foregroundStyle(.secondary) + + if calibratorService.needsRecalibration() { + Label( + "Calibration expired - recalibration recommended", + systemImage: "exclamationmark.triangle.fill" + ) + .font(.caption) + .foregroundStyle(.orange) + } else { + Label("Calibration active and valid", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + } + } + } else { + Text("Not calibrated - using default thresholds") + .font(.caption) + .foregroundStyle(.secondary) + } + + Button(action: { + showCalibrationWindow = true + }) { + HStack { + Image(systemName: "target") + Text( + calibratorService.calibrationData.isComplete + ? "Recalibrate" : "Run Calibration") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .buttonStyle(.bordered) + .controlSize(.regular) + } + .padding(sectionPadding) + .glassEffectIfAvailable( + GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: sectionCornerRadius) + ) + } + + private var testModePreviewView: some View { + VStack(spacing: 16) { + let lookingAway = !eyeTrackingService.userLookingAtScreen + let borderColor: NSColor = lookingAway ? .systemGreen : .systemRed + + let previewLayer = eyeTrackingService.previewLayer ?? cachedPreviewLayer + + if let layer = previewLayer { + ZStack { + CameraPreviewView(previewLayer: layer, borderColor: borderColor) + PupilOverlayView(eyeTrackingService: eyeTrackingService) + + VStack { + HStack { + Spacer() + GazeOverlayView(eyeTrackingService: eyeTrackingService) + } + Spacer() + } + } + .frame(height: presentation.isCard ? 180 : (isCompact ? 200 : 300)) + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) + .onAppear { + if cachedPreviewLayer == nil { + cachedPreviewLayer = eyeTrackingService.previewLayer + } + } + } + } + } + + private var cameraStatusView: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Camera Access") + .font(headerFont) + + if cameraService.isCameraAuthorized { + Label("Authorized", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + } else if let error = cameraService.cameraError { + Label(error.localizedDescription, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } else { + Label("Not authorized", systemImage: "xmark.circle.fill") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + if !cameraService.isCameraAuthorized { + Button("Request Access") { + Task { @MainActor in + do { + try await cameraService.requestCameraAccess() + } catch { + print("⚠️ Camera access failed: \(error.localizedDescription)") + } + } + } + .buttonStyle(.bordered) + .controlSize(presentation.isCard ? .small : .regular) + } + } + .padding(sectionPadding) + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) + } + + private var eyeTrackingStatusView: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Eye Tracking Status") + .font(headerFont) + + HStack(spacing: 20) { + statusIndicator( + title: "Face Detected", + isActive: eyeTrackingService.faceDetected, + icon: "person.fill" + ) + + statusIndicator( + title: "Looking Away", + isActive: !eyeTrackingService.userLookingAtScreen, + icon: "arrow.turn.up.right" + ) + } + } + .padding(sectionPadding) + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) + } + + private func statusIndicator(title: String, isActive: Bool, icon: String) -> some View { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(isActive ? .green : .secondary) + + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + } + + private var privacyInfoView: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "lock.shield.fill") + .font(.title3) + .foregroundStyle(.blue) + Text("Privacy Information") + .font(headerFont) + } + + VStack(alignment: .leading, spacing: 8) { + privacyBullet("All processing happens on-device") + privacyBullet("No images are stored or transmitted") + privacyBullet("Camera only active during lookaway reminders (3 second window)") + privacyBullet("You can always force quit with cmd+q") + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(sectionPadding) + .glassEffectIfAvailable( + GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: sectionCornerRadius) + ) + } + + private func privacyBullet(_ text: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "checkmark") + .font(.caption2) + .foregroundStyle(.blue) + Text(text) + } + } + + private var enforceModeToggleView: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Enable Enforce Mode") + .font(headerFont) + if !cameraHardwareAvailable { + Text("No camera hardware detected") + .font(.caption2) + .foregroundStyle(.orange) + } else { + Text("Camera activates 3 seconds before lookaway reminders") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + Spacer() + Toggle( + "", + isOn: Binding( + get: { + settingsManager.isTimerEnabled(for: .lookAway) + || settingsManager.isTimerEnabled(for: .blink) + || settingsManager.isTimerEnabled(for: .posture) + }, + set: { newValue in + guard !isProcessingToggle else { return } + handleEnforceModeToggle(newValue) + } + ) + ) + .labelsHidden() + .disabled(isProcessingToggle || !cameraHardwareAvailable) + .controlSize(presentation.isCard ? .small : (isCompact ? .small : .regular)) + } + .padding(sectionPadding) + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) + } + + private var trackingConstantsView: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Tracking Sensitivity") + .font(headerFont) + Spacer() + Button(action: { + eyeTrackingService.enableDebugLogging.toggle() + }) { + Image( + systemName: eyeTrackingService.enableDebugLogging + ? "ant.circle.fill" : "ant.circle" + ) + .foregroundStyle(eyeTrackingService.enableDebugLogging ? .orange : .secondary) + } + .buttonStyle(.plain) + .help("Toggle console debug logging") + + Button(showAdvancedSettings ? "Hide Settings" : "Show Settings") { + withAnimation { + showAdvancedSettings.toggle() + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Live Values:") + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + + if let leftRatio = eyeTrackingService.debugLeftPupilRatio, + let rightRatio = eyeTrackingService.debugRightPupilRatio + { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text("Left Pupil: \(String(format: "%.3f", leftRatio))") + .font(.caption2) + .foregroundStyle( + !EyeTrackingConstants.minPupilEnabled + && !EyeTrackingConstants.maxPupilEnabled + ? .secondary + : (leftRatio < EyeTrackingConstants.minPupilRatio + || leftRatio > EyeTrackingConstants.maxPupilRatio) + ? Color.orange : Color.green + ) + Text("Right Pupil: \(String(format: "%.3f", rightRatio))") + .font(.caption2) + .foregroundStyle( + !EyeTrackingConstants.minPupilEnabled + && !EyeTrackingConstants.maxPupilEnabled + ? .secondary + : (rightRatio < EyeTrackingConstants.minPupilRatio + || rightRatio > EyeTrackingConstants.maxPupilRatio) + ? Color.orange : Color.green + ) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text( + "Range: \(String(format: "%.2f", EyeTrackingConstants.minPupilRatio)) - \(String(format: "%.2f", EyeTrackingConstants.maxPupilRatio))" + ) + .font(.caption2) + .foregroundStyle(.secondary) + let bothEyesOut = + (leftRatio < EyeTrackingConstants.minPupilRatio + || leftRatio > EyeTrackingConstants.maxPupilRatio) + && (rightRatio < EyeTrackingConstants.minPupilRatio + || rightRatio > EyeTrackingConstants.maxPupilRatio) + Text(bothEyesOut ? "Both Out ⚠️" : "In Range ✓") + .font(.caption2) + .foregroundStyle(bothEyesOut ? .orange : .green) + } + } + } else { + Text("Pupil data unavailable") + .font(.caption2) + .foregroundStyle(.secondary) + } + + if let yaw = eyeTrackingService.debugYaw, + let pitch = eyeTrackingService.debugPitch + { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text("Yaw: \(String(format: "%.3f", yaw))") + .font(.caption2) + .foregroundStyle( + !EyeTrackingConstants.yawEnabled + ? .secondary + : abs(yaw) > EyeTrackingConstants.yawThreshold + ? Color.orange : Color.green + ) + Text("Pitch: \(String(format: "%.3f", pitch))") + .font(.caption2) + .foregroundStyle( + !EyeTrackingConstants.pitchUpEnabled + && !EyeTrackingConstants.pitchDownEnabled + ? .secondary + : (pitch > EyeTrackingConstants.pitchUpThreshold + || pitch < EyeTrackingConstants.pitchDownThreshold) + ? Color.orange : Color.green + ) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text( + "Yaw Max: \(String(format: "%.2f", EyeTrackingConstants.yawThreshold))" + ) + .font(.caption2) + .foregroundStyle(.secondary) + Text( + "Pitch: \(String(format: "%.2f", EyeTrackingConstants.pitchDownThreshold)) to \(String(format: "%.2f", EyeTrackingConstants.pitchUpThreshold))" + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + .padding(.top, 4) + + if showAdvancedSettings { + VStack(spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Current Threshold Values:") + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + + HStack { + Text("Yaw Threshold:") + Spacer() + Text("\(String(format: "%.2f", EyeTrackingConstants.yawThreshold)) rad") + .foregroundStyle(.secondary) + } + + HStack { + Text("Pitch Up Threshold:") + Spacer() + Text( + "\(String(format: "%.2f", EyeTrackingConstants.pitchUpThreshold)) rad" + ) + .foregroundStyle(.secondary) + } + + HStack { + Text("Pitch Down Threshold:") + Spacer() + Text( + "\(String(format: "%.2f", EyeTrackingConstants.pitchDownThreshold)) rad" + ) + .foregroundStyle(.secondary) + } + + HStack { + Text("Min Pupil Ratio:") + Spacer() + Text("\(String(format: "%.2f", EyeTrackingConstants.minPupilRatio))") + .foregroundStyle(.secondary) + } + + HStack { + Text("Max Pupil Ratio:") + Spacer() + Text("\(String(format: "%.2f", EyeTrackingConstants.maxPupilRatio))") + .foregroundStyle(.secondary) + } + + HStack { + Text("Eye Closed Threshold:") + Spacer() + Text( + "\(String(format: "%.3f", EyeTrackingConstants.eyeClosedThreshold))" + ) + .foregroundStyle(.secondary) + } + } + .padding(.top, 8) + } + .padding(.top, 8) + } + } + .padding(sectionPadding) + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) + } +} diff --git a/Gaze/Views/Components/SmartModeSetupContent.swift b/Gaze/Views/Components/SmartModeSetupContent.swift new file mode 100644 index 0000000..3c5b8b6 --- /dev/null +++ b/Gaze/Views/Components/SmartModeSetupContent.swift @@ -0,0 +1,201 @@ +// +// SmartModeSetupContent.swift +// Gaze +// +// Created by Mike Freno on 1/30/26. +// + +import SwiftUI + +struct SmartModeSetupContent: View { + @Bindable var settingsManager: SettingsManager + @State private var permissionManager = ScreenCapturePermissionManager.shared + let presentation: SetupPresentation + + private var iconSize: CGFloat { + presentation.isCard ? AdaptiveLayout.Font.cardIconSmall : AdaptiveLayout.Font.cardIcon + } + + private var sectionCornerRadius: CGFloat { + presentation.isCard ? 10 : 12 + } + + private var sectionPadding: CGFloat { + presentation.isCard ? 10 : 16 + } + + private var sectionSpacing: CGFloat { + presentation.isCard ? 8 : 12 + } + + var body: some View { + VStack(spacing: presentation.isCard ? 10 : 24) { + if presentation.isCard { + Image(systemName: "brain.fill") + .font(.system(size: iconSize)) + .foregroundStyle(.purple) + + Text("Smart Mode") + .font(.title2) + .fontWeight(.bold) + } + + Text("Automatically manage timers based on your activity") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + if presentation.isCard { + Spacer(minLength: 0) + } + + VStack(spacing: sectionSpacing) { + fullscreenSection + idleSection + #if DEBUG + usageTrackingSection + #endif + } + .frame(maxWidth: presentation.isCard ? .infinity : 600) + + if presentation.isCard { + Spacer(minLength: 0) + } + } + } + + private var fullscreenSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .foregroundStyle(.blue) + Text("Auto-pause on Fullscreen") + .font(presentation.isCard ? .subheadline : .headline) + } + Text("Timers will automatically pause when you enter fullscreen mode (videos, games, presentations)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnFullscreen) + .labelsHidden() + .controlSize(presentation.isCard ? .small : .regular) + .onChange(of: settingsManager.settings.smartMode.autoPauseOnFullscreen) { _, newValue in + if newValue { + permissionManager.requestAuthorizationIfNeeded() + } + } + } + + if settingsManager.settings.smartMode.autoPauseOnFullscreen, + permissionManager.authorizationStatus != .authorized + { + permissionWarningView + } + } + .padding(sectionPadding) + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) + } + + private var permissionWarningView: some View { + VStack(alignment: .leading, spacing: 8) { + Label( + permissionManager.authorizationStatus == .denied + ? "Screen Recording permission required" + : "Grant Screen Recording access", + systemImage: "exclamationmark.shield" + ) + .foregroundStyle(.orange) + + Text("macOS requires Screen Recording permission to detect other apps in fullscreen.") + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Button("Grant Access") { + permissionManager.requestAuthorizationIfNeeded() + permissionManager.openSystemSettings() + } + .buttonStyle(.bordered) + .controlSize(presentation.isCard ? .small : .regular) + + Button("Open Settings") { + permissionManager.openSystemSettings() + } + .buttonStyle(.borderless) + } + .font(.caption) + .padding(.top, 4) + } + .padding(.top, 8) + } + + private var idleSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "moon.zzz.fill") + .foregroundStyle(.indigo) + Text("Auto-pause on Idle") + .font(presentation.isCard ? .subheadline : .headline) + } + Text("Timers will pause when you're inactive for more than the threshold below") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnIdle) + .labelsHidden() + .controlSize(presentation.isCard ? .small : .regular) + } + + if settingsManager.settings.smartMode.autoPauseOnIdle { + ThresholdSlider( + label: "Idle Threshold:", + value: $settingsManager.settings.smartMode.idleThresholdMinutes, + range: 1...30, + unit: "min" + ) + } + } + .padding(sectionPadding) + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) + } + + private var usageTrackingSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "chart.line.uptrend.xyaxis") + .foregroundStyle(.green) + Text("Track Usage Statistics") + .font(presentation.isCard ? .subheadline : .headline) + } + Text("Monitor active and idle time, with automatic reset after the specified duration") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Toggle("", isOn: $settingsManager.settings.smartMode.trackUsage) + .labelsHidden() + .controlSize(presentation.isCard ? .small : .regular) + } + + if settingsManager.settings.smartMode.trackUsage { + ThresholdSlider( + label: "Reset After:", + value: $settingsManager.settings.smartMode.usageResetAfterMinutes, + range: 15...240, + step: 15, + unit: "min" + ) + } + } + .padding(sectionPadding) + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) + } +} diff --git a/Gaze/Views/Components/ThresholdSlider.swift b/Gaze/Views/Components/ThresholdSlider.swift new file mode 100644 index 0000000..7365cb4 --- /dev/null +++ b/Gaze/Views/Components/ThresholdSlider.swift @@ -0,0 +1,39 @@ +// +// ThresholdSlider.swift +// Gaze +// +// Created by Mike Freno on 1/30/26. +// + +import SwiftUI + +struct ThresholdSlider: View { + let label: String + @Binding var value: Int + let range: ClosedRange + var step: Int = 1 + let unit: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(label) + .font(.subheadline) + Spacer() + Text("\(value) \(unit)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Slider( + value: Binding( + get: { Double(value) }, + set: { value = Int($0) } + ), + in: Double(range.lowerBound)...Double(range.upperBound), + step: Double(step) + ) + } + .padding(.top, 8) + } +} diff --git a/Gaze/Views/Containers/AdditionalModifiersView.swift b/Gaze/Views/Containers/AdditionalModifiersView.swift index a3e2839..111f6fa 100644 --- a/Gaze/Views/Containers/AdditionalModifiersView.swift +++ b/Gaze/Views/Containers/AdditionalModifiersView.swift @@ -5,6 +5,7 @@ // Created by Mike Freno on 1/18/26. // +import AVFoundation import SwiftUI struct AdditionalModifiersView: View { @@ -12,6 +13,13 @@ struct AdditionalModifiersView: View { @State private var frontCardIndex: Int = 0 @State private var dragOffset: CGFloat = 0 @State private var isDragging: Bool = false + @State private var isTestModeActive = false + @State private var cachedPreviewLayer: AVCaptureVideoPreviewLayer? + @State private var showAdvancedSettings = false + @State private var showCalibrationWindow = false + @State private var isViewActive = false + @State private var isProcessingToggle = false + @ObservedObject var cameraService = CameraAccessService.shared @Environment(\.isCompactLayout) private var isCompact private var backCardOffset: CGFloat { isCompact ? 20 : AdaptiveLayout.Card.backOffset } @@ -50,15 +58,40 @@ struct AdditionalModifiersView: View { ZStack { #if DEBUG - cardView(for: 0, width: cardWidth, height: cardHeight) - .zIndex(zIndex(for: 0)) - .scaleEffect(scale(for: 0)) - .offset(x: xOffset(for: 0), y: yOffset(for: 0)) + setupCard( + presentation: .card, + content: EnforceModeSetupContent( + settingsManager: settingsManager, + presentation: .card, + isTestModeActive: $isTestModeActive, + cachedPreviewLayer: $cachedPreviewLayer, + showAdvancedSettings: $showAdvancedSettings, + showCalibrationWindow: $showCalibrationWindow, + isViewActive: $isViewActive, + isProcessingToggle: isProcessingToggle, + handleEnforceModeToggle: { enabled in + if enabled { + Task { @MainActor in + try await cameraService.requestCameraAccess() + } + } + } + ), + width: cardWidth, + height: cardHeight, + index: 0 + ) #endif - cardView(for: 1, width: cardWidth, height: cardHeight) - .zIndex(zIndex(for: 1)) - .scaleEffect(scale(for: 1)) - .offset(x: xOffset(for: 1), y: yOffset(for: 1)) + setupCard( + presentation: .card, + content: SmartModeSetupContent( + settingsManager: settingsManager, + presentation: .card + ), + width: cardWidth, + height: cardHeight, + index: 1 + ) } .padding(isCompact ? 12 : 20) .gesture(dragGesture) @@ -198,226 +231,26 @@ struct AdditionalModifiersView: View { // MARK: - Card Views @ViewBuilder - private func cardView(for index: Int, width: CGFloat, height: CGFloat) -> some View { + private func setupCard( + presentation: SetupPresentation, + content: some View, + width: CGFloat, + height: CGFloat, + index: Int + ) -> some View { ZStack { RoundedRectangle(cornerRadius: 16) .fill(Color(NSColor.windowBackgroundColor)) .shadow(color: Color.black.opacity(0.2), radius: 10, x: 0, y: 4) - Group { - if index == 0 { - enforceModeContent - } else { - smartModeContent - } - } - .padding(isCompact ? 12 : 20) + content + .padding(isCompact ? 12 : 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } .frame(width: width, height: height) - } - - @ObservedObject var cameraService = CameraAccessService.shared - - private var enforceModeContent: some View { - VStack(spacing: isCompact ? 10 : 16) { - Image(systemName: "video.fill") - .font( - .system( - size: isCompact - ? AdaptiveLayout.Font.cardIconSmall : AdaptiveLayout.Font.cardIcon) - ) - .foregroundStyle(Color.accentColor) - - Text("Enforce Mode") - .font(isCompact ? .headline : .title2) - .fontWeight(.bold) - - if !cameraService.hasCameraHardware { - Text("Camera hardware not detected") - .font(isCompact ? .caption : .subheadline) - .foregroundStyle(.orange) - .multilineTextAlignment(.center) - } else { - Text("Use your camera to ensure you take breaks") - .font(isCompact ? .caption : .subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - - Spacer() - - VStack(spacing: isCompact ? 10 : 16) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Enable Enforce Mode") - .font(isCompact ? .subheadline : .headline) - if !cameraService.hasCameraHardware { - Text("No camera hardware detected") - .font(.caption2) - .foregroundStyle(.orange) - } else { - Text("Camera activates before lookaway reminders") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - Spacer() - Toggle( - "", - isOn: Binding( - get: { - settingsManager.isTimerEnabled(for: .lookAway) - || settingsManager.isTimerEnabled(for: .blink) - || settingsManager.isTimerEnabled(for: .posture) - }, - set: { newValue in - if newValue { - Task { @MainActor in - try await cameraService.requestCameraAccess() - } - } - } - ) - ) - .labelsHidden() - .disabled(!cameraService.hasCameraHardware) - .controlSize(isCompact ? .small : .regular) - } - .padding(isCompact ? 10 : 16) - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Camera Access") - .font(isCompact ? .subheadline : .headline) - - if !cameraService.hasCameraHardware { - Label("No camera", systemImage: "xmark.circle.fill") - .font(.caption2) - .foregroundStyle(.orange) - } else if cameraService.isCameraAuthorized { - Label("Authorized", systemImage: "checkmark.circle.fill") - .font(.caption2) - .foregroundStyle(.green) - } else if let error = cameraService.cameraError { - Label( - error.localizedDescription, - systemImage: "exclamationmark.triangle.fill" - ) - .font(.caption2) - .foregroundStyle(.orange) - } else { - Label("Not authorized", systemImage: "xmark.circle.fill") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - - Spacer() - - if !cameraService.isCameraAuthorized { - Button("Request Access") { - Task { @MainActor in - do { - try await cameraService.requestCameraAccess() - } catch { - print("Camera access failed: \(error.localizedDescription)") - } - } - } - .buttonStyle(.bordered) - .controlSize(isCompact ? .small : .regular) - } - } - .padding(isCompact ? 10 : 16) - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - } - - Spacer() - } - } - - private var smartModeContent: some View { - VStack(spacing: isCompact ? 10 : 16) { - Image(systemName: "brain.fill") - .font( - .system( - size: isCompact - ? AdaptiveLayout.Font.cardIconSmall : AdaptiveLayout.Font.cardIcon) - ) - .foregroundStyle(.purple) - - Text("Smart Mode") - .font(isCompact ? .headline : .title2) - .fontWeight(.bold) - - Text("Automatically manage timers based on activity") - .font(isCompact ? .caption : .subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - Spacer() - - VStack(spacing: isCompact ? 8 : 12) { - smartModeToggle( - icon: "arrow.up.left.and.arrow.down.right", - iconColor: .blue, - title: "Auto-pause on Fullscreen", - subtitle: "Pause during videos, games, presentations", - isOn: $settingsManager.settings.smartMode.autoPauseOnFullscreen - ) - - smartModeToggle( - icon: "moon.zzz.fill", - iconColor: .indigo, - title: "Auto-pause on Idle", - subtitle: "Pause when you're inactive", - isOn: $settingsManager.settings.smartMode.autoPauseOnIdle - ) - - #if DEBUG - smartModeToggle( - icon: "chart.line.uptrend.xyaxis", - iconColor: .green, - title: "Track Usage Statistics", - subtitle: "Monitor active and idle time", - isOn: $settingsManager.settings.smartMode.trackUsage - ) - #endif - } - - Spacer() - } - } - - @ViewBuilder - private func smartModeToggle( - icon: String, iconColor: Color, title: String, subtitle: String, isOn: Binding - ) -> some View { - HStack { - Image(systemName: icon) - .foregroundStyle(iconColor) - .frame(width: isCompact ? 20 : 24) - - VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(isCompact ? .caption : .subheadline) - .fontWeight(.medium) - Text(subtitle) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - - Toggle("", isOn: isOn) - .labelsHidden() - .controlSize(.small) - } - .padding(.horizontal, isCompact ? 8 : 12) - .padding(.vertical, isCompact ? 6 : 10) - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 10)) + .zIndex(zIndex(for: index)) + .scaleEffect(scale(for: index)) + .offset(x: xOffset(for: index), y: yOffset(for: index)) } // MARK: - Gestures & Navigation diff --git a/Gaze/Views/Setup/EnforceModeSetupView.swift b/Gaze/Views/Setup/EnforceModeSetupView.swift index 8d9828a..5370264 100644 --- a/Gaze/Views/Setup/EnforceModeSetupView.swift +++ b/Gaze/Views/Setup/EnforceModeSetupView.swift @@ -6,15 +6,12 @@ // import AVFoundation -import Foundation import SwiftUI struct EnforceModeSetupView: View { @Bindable var settingsManager: SettingsManager @ObservedObject var cameraService = CameraAccessService.shared - @ObservedObject var eyeTrackingService = EyeTrackingService.shared @ObservedObject var enforceModeService = EnforceModeService.shared - @Environment(\.isCompactLayout) private var isCompact @State private var isProcessingToggle = false @State private var isTestModeActive = false @@ -23,7 +20,6 @@ struct EnforceModeSetupView: View { @State private var isViewActive = false @State private var showAdvancedSettings = false @State private var showCalibrationWindow = false - @ObservedObject var calibratorService = CalibratorService.shared private var cameraHardwareAvailable: Bool { cameraService.hasCameraHardware @@ -33,72 +29,27 @@ struct EnforceModeSetupView: View { VStack(spacing: 0) { SetupHeader(icon: "video.fill", title: "Enforce Mode", color: .accentColor) - Spacer() - - VStack(spacing: isCompact ? 16 : 30) { - Text("Use your camera to ensure you take breaks") - .font(isCompact ? .subheadline : .title3) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - VStack(spacing: isCompact ? 12 : 20) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Enable Enforce Mode") - .font(isCompact ? .subheadline : .headline) - if !cameraHardwareAvailable { - Text("No camera hardware detected") - .font(.caption2) - .foregroundStyle(.orange) - } else { - Text("Camera activates 3 seconds before lookaway reminders") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - Spacer() - Toggle( - "", - isOn: Binding( - get: { - settingsManager.isTimerEnabled(for: .lookAway) || - settingsManager.isTimerEnabled(for: .blink) || - settingsManager.isTimerEnabled(for: .posture) - }, - set: { newValue in - print("🎛️ Toggle changed to: \(newValue)") - guard !isProcessingToggle else { - print("⚠️ Already processing toggle") - return - } - handleEnforceModeToggle(enabled: newValue) - } - ) - ) - .labelsHidden() - .disabled(isProcessingToggle || !cameraHardwareAvailable) - .controlSize(isCompact ? .small : .regular) + EnforceModeSetupContent( + settingsManager: settingsManager, + presentation: .window, + isTestModeActive: $isTestModeActive, + cachedPreviewLayer: $cachedPreviewLayer, + showAdvancedSettings: $showAdvancedSettings, + showCalibrationWindow: $showCalibrationWindow, + isViewActive: $isViewActive, + isProcessingToggle: isProcessingToggle, + handleEnforceModeToggle: { enabled in + print("🎛️ Toggle changed to: \(enabled)") + guard !isProcessingToggle else { + print("⚠️ Already processing toggle") + return } - .padding(isCompact ? 10 : 16) - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - - cameraStatusView - - if enforceModeService.isEnforceModeEnabled { - testModeButton - } - if isTestModeActive && enforceModeService.isCameraActive { - testModePreviewView - trackingConstantsView - } else if enforceModeService.isCameraActive && !isTestModeActive { - eyeTrackingStatusView - trackingConstantsView - } - privacyInfoView + handleEnforceModeToggle(enabled: enabled) } - } + ) + .padding(.top, 20) - Spacer() + Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() @@ -115,272 +66,6 @@ struct EnforceModeSetupView: View { } } } - - private var testModeButton: some View { - Button(action: { - Task { @MainActor in - if isTestModeActive { - enforceModeService.stopTestMode() - isTestModeActive = false - cachedPreviewLayer = nil - } else { - await enforceModeService.startTestMode() - isTestModeActive = enforceModeService.isCameraActive - if isTestModeActive { - cachedPreviewLayer = eyeTrackingService.previewLayer - } - } - } - }) { - HStack { - Image(systemName: isTestModeActive ? "stop.circle.fill" : "play.circle.fill") - .font(.title3) - Text(isTestModeActive ? "Stop Test" : "Test Tracking") - .font(.headline) - } - .frame(maxWidth: .infinity) - .padding() - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - } - - private var calibrationSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemName: "target") - .font(.title3) - .foregroundStyle(.blue) - Text("Eye Tracking Calibration") - .font(.headline) - } - - if calibratorService.calibrationData.isComplete { - VStack(alignment: .leading, spacing: 8) { - Text(calibratorService.getCalibrationSummary()) - .font(.caption) - .foregroundStyle(.secondary) - - if calibratorService.needsRecalibration() { - Label( - "Calibration expired - recalibration recommended", - systemImage: "exclamationmark.triangle.fill" - ) - .font(.caption) - .foregroundStyle(.orange) - } else { - Label("Calibration active and valid", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(.green) - } - } - } else { - Text("Not calibrated - using default thresholds") - .font(.caption) - .foregroundStyle(.secondary) - } - - Button(action: { - showCalibrationWindow = true - }) { - HStack { - Image(systemName: "target") - Text( - calibratorService.calibrationData.isComplete - ? "Recalibrate" : "Run Calibration") - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - } - .buttonStyle(.bordered) - .controlSize(.regular) - } - .padding() - .glassEffectIfAvailable( - GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: 12) - ) - .sheet(isPresented: $showCalibrationWindow) { - EyeTrackingCalibrationView() - } - } - - private var testModePreviewView: some View { - VStack(spacing: 16) { - let lookingAway = !eyeTrackingService.userLookingAtScreen - let borderColor: NSColor = lookingAway ? .systemGreen : .systemRed - - // Cache the preview layer to avoid recreating it - let previewLayer = eyeTrackingService.previewLayer ?? cachedPreviewLayer - - if let layer = previewLayer { - ZStack { - CameraPreviewView(previewLayer: layer, borderColor: borderColor) - - // Pupil detection overlay (drawn on video) - PupilOverlayView(eyeTrackingService: eyeTrackingService) - - // Debug info overlay (top-right corner) - VStack { - HStack { - Spacer() - GazeOverlayView(eyeTrackingService: eyeTrackingService) - } - Spacer() - } - } - .frame(height: isCompact ? 200 : 300) - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - .onAppear { - if cachedPreviewLayer == nil { - cachedPreviewLayer = eyeTrackingService.previewLayer - } - } - - /*VStack(alignment: .leading, spacing: 12) {*/ - /*Text("Live Tracking Status")*/ - /*.font(.headline)*/ - - /*HStack(spacing: 20) {*/ - /*statusIndicator(*/ - /*title: "Face Detected",*/ - /*isActive: eyeTrackingService.faceDetected,*/ - /*icon: "person.fill"*/ - /*)*/ - - /*statusIndicator(*/ - /*title: "Looking Away",*/ - /*isActive: !eyeTrackingService.userLookingAtScreen,*/ - /*icon: "arrow.turn.up.right"*/ - /*)*/ - /*}*/ - - /*Text(*/ - /*lookingAway*/ - /*? "✓ Break compliance detected" : "⚠️ Please look away from screen"*/ - /*)*/ - /*.font(.caption)*/ - /*.foregroundStyle(lookingAway ? .green : .orange)*/ - /*.frame(maxWidth: .infinity, alignment: .center)*/ - /*.padding(.top, 4)*/ - /*}*/ - /*.padding()*/ - /*.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))*/ - } - } - } - - private var cameraStatusView: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Camera Access") - .font(.headline) - - if cameraService.isCameraAuthorized { - Label("Authorized", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(.green) - } else if let error = cameraService.cameraError { - Label(error.localizedDescription, systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.orange) - } else { - Label("Not authorized", systemImage: "xmark.circle.fill") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer() - - if !cameraService.isCameraAuthorized { - Button("Request Access") { - print("📷 Request Access button clicked") - Task { @MainActor in - do { - try await cameraService.requestCameraAccess() - print("✓ Camera access granted via button") - } catch { - print("⚠️ Camera access failed: \(error.localizedDescription)") - } - } - } - .buttonStyle(.bordered) - } - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - } - - private var eyeTrackingStatusView: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Eye Tracking Status") - .font(.headline) - - HStack(spacing: 20) { - statusIndicator( - title: "Face Detected", - isActive: eyeTrackingService.faceDetected, - icon: "person.fill" - ) - - statusIndicator( - title: "Looking Away", - isActive: !eyeTrackingService.userLookingAtScreen, - icon: "arrow.turn.up.right" - ) - } - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - } - - private func statusIndicator(title: String, isActive: Bool, icon: String) -> some View { - VStack(spacing: 8) { - Image(systemName: icon) - .font(.title2) - .foregroundStyle(isActive ? .green : .secondary) - - Text(title) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - } - - private var privacyInfoView: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemName: "lock.shield.fill") - .font(.title3) - .foregroundStyle(.blue) - Text("Privacy Information") - .font(.headline) - } - - VStack(alignment: .leading, spacing: 8) { - privacyBullet("All processing happens on-device") - privacyBullet("No images are stored or transmitted") - privacyBullet("Camera only active during lookaway reminders (3 second window)") - privacyBullet("You can always force quit with cmd+q") - } - .font(.caption) - .foregroundStyle(.secondary) - } - .padding() - .glassEffectIfAvailable( - GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: 12)) - } - - private func privacyBullet(_ text: String) -> some View { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "checkmark") - .font(.caption2) - .foregroundStyle(.blue) - Text(text) - } - } - private func handleEnforceModeToggle(enabled: Bool) { print("🎛️ handleEnforceModeToggle called with enabled: \(enabled)") isProcessingToggle = true @@ -411,232 +96,6 @@ struct EnforceModeSetupView: View { } } } - - private var trackingConstantsView: some View { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Tracking Sensitivity") - .font(.headline) - Spacer() - Button(action: { - eyeTrackingService.enableDebugLogging.toggle() - }) { - Image( - systemName: eyeTrackingService.enableDebugLogging - ? "ant.circle.fill" : "ant.circle" - ) - .foregroundStyle(eyeTrackingService.enableDebugLogging ? .orange : .secondary) - } - .buttonStyle(.plain) - .help("Toggle console debug logging") - - Button(showAdvancedSettings ? "Hide Settings" : "Show Settings") { - withAnimation { - showAdvancedSettings.toggle() - } - } - .buttonStyle(.bordered) - .controlSize(.small) - } - - // Debug info always visible when tracking - VStack(alignment: .leading, spacing: 8) { - Text("Live Values:") - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(.secondary) - - if let leftRatio = eyeTrackingService.debugLeftPupilRatio, - let rightRatio = eyeTrackingService.debugRightPupilRatio - { - HStack(spacing: 16) { - VStack(alignment: .leading, spacing: 2) { - Text("Left Pupil: \(String(format: "%.3f", leftRatio))") - .font(.caption2) - .foregroundStyle( - !EyeTrackingConstants.minPupilEnabled - && !EyeTrackingConstants.maxPupilEnabled - ? .secondary - : (leftRatio < EyeTrackingConstants.minPupilRatio - || leftRatio > EyeTrackingConstants.maxPupilRatio) - ? Color.orange : Color.green - ) - Text("Right Pupil: \(String(format: "%.3f", rightRatio))") - .font(.caption2) - .foregroundStyle( - !EyeTrackingConstants.minPupilEnabled - && !EyeTrackingConstants.maxPupilEnabled - ? .secondary - : (rightRatio < EyeTrackingConstants.minPupilRatio - || rightRatio > EyeTrackingConstants.maxPupilRatio) - ? Color.orange : Color.green - ) - } - - Spacer() - - VStack(alignment: .trailing, spacing: 2) { - Text( - "Range: \(String(format: "%.2f", EyeTrackingConstants.minPupilRatio)) - \(String(format: "%.2f", EyeTrackingConstants.maxPupilRatio))" - ) - .font(.caption2) - .foregroundStyle(.secondary) - let bothEyesOut = - (leftRatio < EyeTrackingConstants.minPupilRatio - || leftRatio > EyeTrackingConstants.maxPupilRatio) - && (rightRatio < EyeTrackingConstants.minPupilRatio - || rightRatio > EyeTrackingConstants.maxPupilRatio) - Text(bothEyesOut ? "Both Out ⚠️" : "In Range ✓") - .font(.caption2) - .foregroundStyle(bothEyesOut ? .orange : .green) - } - } - } else { - Text("Pupil data unavailable") - .font(.caption2) - .foregroundStyle(.secondary) - } - - if let yaw = eyeTrackingService.debugYaw, - let pitch = eyeTrackingService.debugPitch - { - HStack(spacing: 16) { - VStack(alignment: .leading, spacing: 2) { - Text("Yaw: \(String(format: "%.3f", yaw))") - .font(.caption2) - .foregroundStyle( - !EyeTrackingConstants.yawEnabled - ? .secondary - : abs(yaw) > EyeTrackingConstants.yawThreshold - ? Color.orange : Color.green - ) - Text("Pitch: \(String(format: "%.3f", pitch))") - .font(.caption2) - .foregroundStyle( - !EyeTrackingConstants.pitchUpEnabled - && !EyeTrackingConstants.pitchDownEnabled - ? .secondary - : (pitch > EyeTrackingConstants.pitchUpThreshold - || pitch < EyeTrackingConstants.pitchDownThreshold) - ? Color.orange : Color.green - ) - } - - Spacer() - - VStack(alignment: .trailing, spacing: 2) { - Text( - "Yaw Max: \(String(format: "%.2f", EyeTrackingConstants.yawThreshold))" - ) - .font(.caption2) - .foregroundStyle(.secondary) - Text( - "Pitch: \(String(format: "%.2f", EyeTrackingConstants.pitchDownThreshold)) to \(String(format: "%.2f", EyeTrackingConstants.pitchUpThreshold))" - ) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - } - } - .padding(.top, 4) - - if showAdvancedSettings { - VStack(spacing: 16) { - // Display the current constant values - VStack(alignment: .leading, spacing: 8) { - Text("Current Threshold Values:") - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(.secondary) - - HStack { - Text("Yaw Threshold:") - Spacer() - Text("\(String(format: "%.2f", EyeTrackingConstants.yawThreshold)) rad") - .foregroundStyle(.secondary) - } - - HStack { - Text("Pitch Up Threshold:") - Spacer() - Text( - "\(String(format: "%.2f", EyeTrackingConstants.pitchUpThreshold)) rad" - ) - .foregroundStyle(.secondary) - } - - HStack { - Text("Pitch Down Threshold:") - Spacer() - Text( - "\(String(format: "%.2f", EyeTrackingConstants.pitchDownThreshold)) rad" - ) - .foregroundStyle(.secondary) - } - - HStack { - Text("Min Pupil Ratio:") - Spacer() - Text("\(String(format: "%.2f", EyeTrackingConstants.minPupilRatio))") - .foregroundStyle(.secondary) - } - - HStack { - Text("Max Pupil Ratio:") - Spacer() - Text("\(String(format: "%.2f", EyeTrackingConstants.maxPupilRatio))") - .foregroundStyle(.secondary) - } - - HStack { - Text("Eye Closed Threshold:") - Spacer() - Text( - "\(String(format: "%.3f", EyeTrackingConstants.eyeClosedThreshold))" - ) - .foregroundStyle(.secondary) - } - } - .padding(.top, 8) - } - .padding(.top, 8) - } - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - } - - private var debugEyeTrackingView: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Debug Eye Tracking Data") - .font(.headline) - .foregroundStyle(.blue) - - VStack(alignment: .leading, spacing: 8) { - Text("Face Detected: \(eyeTrackingService.faceDetected ? "Yes" : "No")") - .font(.caption) - - Text("Looking at Screen: \(eyeTrackingService.userLookingAtScreen ? "Yes" : "No")") - .font(.caption) - - Text("Eyes Closed: \(eyeTrackingService.isEyesClosed ? "Yes" : "No")") - .font(.caption) - - if eyeTrackingService.faceDetected { - Text("Yaw: 0.0") - .font(.caption) - - Text("Roll: 0.0") - .font(.caption) - } - } - .font(.caption) - .foregroundStyle(.secondary) - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - } } #Preview { diff --git a/Gaze/Views/Setup/SmartModeSetupView.swift b/Gaze/Views/Setup/SmartModeSetupView.swift index 14f9d14..ba60c7d 100644 --- a/Gaze/Views/Setup/SmartModeSetupView.swift +++ b/Gaze/Views/Setup/SmartModeSetupView.swift @@ -9,201 +9,23 @@ import SwiftUI struct SmartModeSetupView: View { @Bindable var settingsManager: SettingsManager - @State private var permissionManager = ScreenCapturePermissionManager.shared var body: some View { VStack(spacing: 0) { SetupHeader(icon: "brain.fill", title: "Smart Mode", color: .purple) - Text("Automatically manage timers based on your activity") - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.bottom, 30) + SmartModeSetupContent( + settingsManager: settingsManager, + presentation: .window + ) + .padding(.top, 24) - Spacer() - - VStack(spacing: 24) { - fullscreenSection - idleSection - #if DEBUG - usageTrackingSection - #endif - } - .frame(maxWidth: 600) - - Spacer() + Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() .background(.clear) } - - private var fullscreenSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack { - Image(systemName: "arrow.up.left.and.arrow.down.right") - .foregroundStyle(.blue) - Text("Auto-pause on Fullscreen") - .font(.headline) - } - Text( - "Timers will automatically pause when you enter fullscreen mode (videos, games, presentations)" - ) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnFullscreen) - .labelsHidden() - .onChange(of: settingsManager.settings.smartMode.autoPauseOnFullscreen) { - _, newValue in - if newValue { - permissionManager.requestAuthorizationIfNeeded() - } - } - } - - if settingsManager.settings.smartMode.autoPauseOnFullscreen, - permissionManager.authorizationStatus != .authorized - { - permissionWarningView - } - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8)) - } - - private var permissionWarningView: some View { - VStack(alignment: .leading, spacing: 8) { - Label( - permissionManager.authorizationStatus == .denied - ? "Screen Recording permission required" - : "Grant Screen Recording access", - systemImage: "exclamationmark.shield" - ) - .foregroundStyle(.orange) - - Text("macOS requires Screen Recording permission to detect other apps in fullscreen.") - .font(.caption) - .foregroundStyle(.secondary) - - HStack { - Button("Grant Access") { - permissionManager.requestAuthorizationIfNeeded() - permissionManager.openSystemSettings() - } - .buttonStyle(.bordered) - - Button("Open Settings") { - permissionManager.openSystemSettings() - } - .buttonStyle(.borderless) - } - .font(.caption) - .padding(.top, 4) - } - .padding(.top, 8) - } - - private var idleSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack { - Image(systemName: "moon.zzz.fill") - .foregroundStyle(.indigo) - Text("Auto-pause on Idle") - .font(.headline) - } - Text("Timers will pause when you're inactive for more than the threshold below") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnIdle) - .labelsHidden() - } - - if settingsManager.settings.smartMode.autoPauseOnIdle { - ThresholdSlider( - label: "Idle Threshold:", - value: $settingsManager.settings.smartMode.idleThresholdMinutes, - range: 1...30, - unit: "min" - ) - } - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8)) - } - - private var usageTrackingSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack { - Image(systemName: "chart.line.uptrend.xyaxis") - .foregroundStyle(.green) - Text("Track Usage Statistics") - .font(.headline) - } - Text( - "Monitor active and idle time, with automatic reset after the specified duration" - ) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Toggle("", isOn: $settingsManager.settings.smartMode.trackUsage) - .labelsHidden() - } - - if settingsManager.settings.smartMode.trackUsage { - ThresholdSlider( - label: "Reset After:", - value: $settingsManager.settings.smartMode.usageResetAfterMinutes, - range: 15...240, - step: 15, - unit: "min" - ) - } - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8)) - } -} - -struct ThresholdSlider: View { - let label: String - @Binding var value: Int - let range: ClosedRange - var step: Int = 1 - let unit: String - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(label) - .font(.subheadline) - Spacer() - Text("\(value) \(unit)") - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Slider( - value: Binding( - get: { Double(value) }, - set: { value = Int($0) } - ), - in: Double(range.lowerBound)...Double(range.upperBound), - step: Double(step) - ) - } - .padding(.top, 8) - } } #Preview {