diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index 0dfe686..b4543a1 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -76,8 +76,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } } - // Note: Smart mode setup is now handled by ServiceContainer - // Keeping this method for settings change observation private func observeSmartModeSettings() { settingsManager.settingsPublisher .map { $0.smartMode } diff --git a/Gaze/Constants/AdaptiveLayout.swift b/Gaze/Constants/AdaptiveLayout.swift index dff916c..acef513 100644 --- a/Gaze/Constants/AdaptiveLayout.swift +++ b/Gaze/Constants/AdaptiveLayout.swift @@ -44,6 +44,26 @@ enum AdaptiveLayout { static let heroTitleSmall: CGFloat = 24 static let cardIcon: CGFloat = 32 static let cardIconSmall: CGFloat = 28 + + /// Returns a responsive font size based on available space + static func responsiveHeroIcon(for size: CGFloat) -> CGFloat { + size < 600 ? heroIconSmall : heroIcon + } + + /// Returns a responsive font size based on available space + static func responsiveHeroTitle(for size: CGFloat) -> CGFloat { + size < 600 ? heroTitleSmall : heroTitle + } + + /// Returns a responsive font size based on available space + static func responsiveCardIcon(for size: CGFloat) -> CGFloat { + size < 600 ? cardIconSmall : cardIcon + } + + /// Returns a responsive spacing value based on available space + static func responsiveSpacing(for size: CGFloat) -> CGFloat { + size < 600 ? AdaptiveLayout.Spacing.compact : AdaptiveLayout.Spacing.standard + } } /// Spacing values @@ -63,6 +83,26 @@ enum AdaptiveLayout { static let backOffset: CGFloat = 24 static let backScale: CGFloat = 0.92 } + + /// Returns a width that scales based on available screen size + static func responsiveWidth( + baseWidth: CGFloat, + scaleFactor: CGFloat = 1.0, + minScale: CGFloat = 0.6 + ) -> CGFloat { + let scaleFactor = min(max(scaleFactor, minScale), 1.0) + return baseWidth * scaleFactor + } + + /// Returns a height that scales based on available screen size + static func responsiveHeight( + baseHeight: CGFloat, + scaleFactor: CGFloat = 1.0, + minScale: CGFloat = 0.6 + ) -> CGFloat { + let scaleFactor = min(max(scaleFactor, minScale), 1.0) + return baseHeight * scaleFactor + } } /// Environment key to determine if we're in a compact layout diff --git a/Gaze/Services/CameraAccessService.swift b/Gaze/Services/CameraAccessService.swift index 8ac61e8..b8cc9f0 100644 --- a/Gaze/Services/CameraAccessService.swift +++ b/Gaze/Services/CameraAccessService.swift @@ -14,9 +14,11 @@ class CameraAccessService: ObservableObject { @Published var isCameraAuthorized = false @Published var cameraError: Error? + @Published var hasCameraHardware = false private init() { checkCameraAuthorizationStatus() + checkCameraHardware() } func requestCameraAccess() async throws { @@ -67,6 +69,15 @@ class CameraAccessService: ObservableObject { cameraError = CameraAccessError.unknown } } + + func checkCameraHardware() { + let devices = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInWideAngleCamera], + mediaType: .video, + position: .unspecified + ).devices + hasCameraHardware = !devices.isEmpty + } // New method to check if face detection is supported and available func isFaceDetectionAvailable() -> Bool { diff --git a/Gaze/Services/SystemSleepManager.swift b/Gaze/Services/SystemSleepManager.swift index 1367a9e..1087fb8 100644 --- a/Gaze/Services/SystemSleepManager.swift +++ b/Gaze/Services/SystemSleepManager.swift @@ -50,13 +50,13 @@ final class SystemSleepManager { observers.removeAll() } - private func handleSystemWillSleep() { + func handleSystemWillSleep() { logInfo("System will sleep") timerEngine?.stop() settingsManager.saveImmediately() } - private func handleSystemDidWake() { + func handleSystemDidWake() { logInfo("System did wake") timerEngine?.start() } diff --git a/Gaze/Views/Components/SetupHeader.swift b/Gaze/Views/Components/SetupHeader.swift index d458b6f..bf6976f 100644 --- a/Gaze/Views/Components/SetupHeader.swift +++ b/Gaze/Views/Components/SetupHeader.swift @@ -20,8 +20,8 @@ struct SetupHeader: View { Text(title) .font(.system(size: 28, weight: .bold)) } - .padding(.top, 20) - .padding(.bottom, 30) + .padding(.top, 15) + .padding(.bottom, 20) } } diff --git a/Gaze/Views/Components/VisualEffectView.swift b/Gaze/Views/Components/VisualEffectView.swift new file mode 100644 index 0000000..623a563 --- /dev/null +++ b/Gaze/Views/Components/VisualEffectView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct VisualEffectView: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blendingMode + view.state = .active + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + nsView.material = material + nsView.blendingMode = blendingMode + } +} diff --git a/Gaze/Views/Containers/AdditionalModifiersView.swift b/Gaze/Views/Containers/AdditionalModifiersView.swift index a9b01e7..fb978d2 100644 --- a/Gaze/Views/Containers/AdditionalModifiersView.swift +++ b/Gaze/Views/Containers/AdditionalModifiersView.swift @@ -19,8 +19,8 @@ struct AdditionalModifiersView: View { var body: some View { GeometryReader { geometry in - let availableWidth = geometry.size.width - 80 // Account for padding - let availableHeight = geometry.size.height - 200 // Account for header and nav + let availableWidth = geometry.size.width - 60 // Account for padding + let availableHeight = geometry.size.height - 160 // Account for header and nav let cardWidth = min( max(availableWidth * 0.85, AdaptiveLayout.Card.minWidth), @@ -39,7 +39,6 @@ struct AdditionalModifiersView: View { .font(isCompact ? .subheadline : .title3) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - .padding(.bottom, isCompact ? 12 : 20) Spacer() @@ -209,6 +208,8 @@ struct AdditionalModifiersView: View { .frame(width: width, height: height) } + @ObservedObject var cameraService = CameraAccessService.shared + private var enforceModeContent: some View { VStack(spacing: isCompact ? 10 : 16) { Image(systemName: "video.fill") @@ -223,10 +224,17 @@ struct AdditionalModifiersView: View { .font(isCompact ? .headline : .title2) .fontWeight(.bold) - Text("Use your camera to ensure you take breaks") - .font(isCompact ? .caption : .subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + 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() @@ -235,13 +243,20 @@ struct AdditionalModifiersView: View { VStack(alignment: .leading, spacing: 2) { Text("Enable Enforce Mode") .font(isCompact ? .subheadline : .headline) - Text("Camera activates before lookaway reminders") - .font(.caption2) - .foregroundStyle(.secondary) + 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: $settingsManager.settings.enforcementMode) .labelsHidden() + .disabled(!cameraService.hasCameraHardware) .controlSize(isCompact ? .small : .regular) } .padding(isCompact ? 10 : 16) @@ -252,11 +267,15 @@ struct AdditionalModifiersView: View { Text("Camera Access") .font(isCompact ? .subheadline : .headline) - if CameraAccessService.shared.isCameraAuthorized { + 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 = CameraAccessService.shared.cameraError { + } else if let error = cameraService.cameraError { Label( error.localizedDescription, systemImage: "exclamationmark.triangle.fill" @@ -272,11 +291,11 @@ struct AdditionalModifiersView: View { Spacer() - if !CameraAccessService.shared.isCameraAuthorized { + if !cameraService.isCameraAuthorized { Button("Request Access") { Task { @MainActor in do { - try await CameraAccessService.shared.requestCameraAccess() + try await cameraService.requestCameraAccess() } catch { print("Camera access failed: \(error.localizedDescription)") } diff --git a/Gaze/Views/Containers/OnboardingContainerView.swift b/Gaze/Views/Containers/OnboardingContainerView.swift index 971bfe0..346efe3 100644 --- a/Gaze/Views/Containers/OnboardingContainerView.swift +++ b/Gaze/Views/Containers/OnboardingContainerView.swift @@ -7,24 +7,6 @@ import SwiftUI -struct VisualEffectView: NSViewRepresentable { - let material: NSVisualEffectView.Material - let blendingMode: NSVisualEffectView.BlendingMode - - func makeNSView(context: Context) -> NSVisualEffectView { - let view = NSVisualEffectView() - view.material = material - view.blendingMode = blendingMode - view.state = .active - return view - } - - func updateNSView(_ nsView: NSVisualEffectView, context: Context) { - nsView.material = material - nsView.blendingMode = blendingMode - } -} - @MainActor final class OnboardingWindowPresenter { static let shared = OnboardingWindowPresenter() @@ -78,11 +60,16 @@ final class OnboardingWindowPresenter { } private func createWindow(settingsManager: SettingsManager) { + let responsiveWidth = AdaptiveLayout.responsiveWidth( + baseWidth: AdaptiveLayout.Window.defaultWidth) + let responsiveHeight = AdaptiveLayout.responsiveHeight( + baseHeight: AdaptiveLayout.Window.defaultHeight) + let window = NSWindow( contentRect: NSRect( x: 0, y: 0, - width: AdaptiveLayout.Window.defaultWidth, - height: AdaptiveLayout.Window.defaultHeight + width: responsiveWidth, + height: responsiveHeight ), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, @@ -178,8 +165,10 @@ struct OnboardingContainerView: View { .environment(\.isCompactLayout, isCompact) } .frame( - minWidth: AdaptiveLayout.Window.minWidth, - minHeight: AdaptiveLayout.Window.minHeight + minWidth: AdaptiveLayout.Window.minWidth * 0.6, + maxWidth: AdaptiveLayout.Window.defaultWidth * 1.1, + minHeight: AdaptiveLayout.Window.minHeight, + maxHeight: AdaptiveLayout.Window.defaultHeight * 1.1 ) .onAppear { MenuBarGuideOverlayPresenter.shared.updateVisibility(isVisible: currentPage == 1) @@ -239,7 +228,7 @@ struct OnboardingContainerView: View { ) } .padding(.horizontal, isCompact ? 24 : 40) - .padding(.bottom, isCompact ? 12 : 20) + .padding(.vertical, isCompact ? 12 : 20) } private func completeOnboarding() { diff --git a/Gaze/Views/Containers/SettingsWindowView.swift b/Gaze/Views/Containers/SettingsWindowView.swift index bd992a6..86165e8 100644 --- a/Gaze/Views/Containers/SettingsWindowView.swift +++ b/Gaze/Views/Containers/SettingsWindowView.swift @@ -34,7 +34,12 @@ struct SettingsWindowView: View { } .environment(\.isCompactLayout, isCompact) } - .frame(minWidth: AdaptiveLayout.Window.minWidth, minHeight: AdaptiveLayout.Window.minHeight) + .frame( + minWidth: AdaptiveLayout.Window.minWidth * 0.7, + maxWidth: AdaptiveLayout.Window.defaultWidth * 1.2, + minHeight: AdaptiveLayout.Window.minHeight, + maxHeight: AdaptiveLayout.Window.defaultHeight * 1.2 + ) .onReceive(tabSwitchPublisher) { notification in if let tab = notification.object as? Int, let section = SettingsSection(rawValue: tab) { diff --git a/Gaze/Views/MenuBar/MenuBarContentView.swift b/Gaze/Views/MenuBar/MenuBarContentView.swift index f459e80..1ea8033 100644 --- a/Gaze/Views/MenuBar/MenuBarContentView.swift +++ b/Gaze/Views/MenuBar/MenuBarContentView.swift @@ -151,47 +151,10 @@ struct MenuBarContentView: View { Divider() } else { - VStack(spacing: 4) { - Button(action: { - onOpenOnboarding() - }) { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(Color.accentColor) - Text("Complete Onboarding") - Spacer() - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - } - .buttonStyle(MenuBarHoverButtonStyle()) - } - .padding(.vertical, 8) - .padding(.horizontal, 8) + IncompleteOnboardingView(onOpenOnboarding: onOpenOnboarding) } - HStack { - Button(action: onQuit) { - HStack { - Image(systemName: "power") - .foregroundStyle(.red) - Text("Quit Gaze") - Spacer() - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - } - .buttonStyle(MenuBarHoverButtonStyle()) - .padding(.vertical, 8) - Spacer() - Text( - "v\(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0")" - ) - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) + QuitRow(onQuit: onQuit) } .frame(width: 300) @@ -226,6 +189,69 @@ struct MenuBarContentView: View { } } } +struct IncompleteOnboardingView: View { + @State private var isHovering = false + + let onOpenOnboarding: () -> Void + + var body: some View { + VStack(spacing: 4) { + Button(action: { + onOpenOnboarding() + }) { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(isHovering ? .white : .accentColor) + Text("Complete Onboarding") + .foregroundStyle(isHovering ? .white : .primary) + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .onHover { hovering in + isHovering = hovering + } + } + .buttonStyle(MenuBarHoverButtonStyle()) + } + .padding(.vertical, 8) + .padding(.horizontal, 8) + } +} +struct QuitRow: View { + @State private var isHovering = false + + let onQuit: () -> Void + + var body: some View { + HStack { + Button(action: onQuit) { + HStack { + Image(systemName: "power") + .foregroundStyle(isHovering ? .white : .red) + Text("Quit Gaze") + .foregroundStyle(isHovering ? .white : .primary) + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .onHover { hovering in + isHovering = hovering + } + } + .buttonStyle(MenuBarHoverButtonStyle()) + .padding(.vertical, 8) + Spacer() + Text( + "v\(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0")" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } +} struct TimerStatusRowWithIndividualControls: View { let identifier: TimerIdentifier diff --git a/Gaze/Views/Setup/EnforceModeSetupView.swift b/Gaze/Views/Setup/EnforceModeSetupView.swift index 51560cf..a2fd0e6 100644 --- a/Gaze/Views/Setup/EnforceModeSetupView.swift +++ b/Gaze/Views/Setup/EnforceModeSetupView.swift @@ -25,6 +25,10 @@ struct EnforceModeSetupView: View { @State private var showCalibrationWindow = false @ObservedObject var calibrationManager = CalibrationManager.shared + private var cameraHardwareAvailable: Bool { + cameraService.hasCameraHardware + } + var body: some View { VStack(spacing: 0) { SetupHeader(icon: "video.fill", title: "Enforce Mode", color: .accentColor) @@ -42,9 +46,15 @@ struct EnforceModeSetupView: View { VStack(alignment: .leading, spacing: 2) { Text("Enable Enforce Mode") .font(isCompact ? .subheadline : .headline) - Text("Camera activates 3 seconds before lookaway reminders") - .font(.caption2) - .foregroundStyle(.secondary) + 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( @@ -65,7 +75,7 @@ struct EnforceModeSetupView: View { ) ) .labelsHidden() - .disabled(isProcessingToggle) + .disabled(isProcessingToggle || !cameraHardwareAvailable) .controlSize(isCompact ? .small : .regular) } .padding(isCompact ? 10 : 16) @@ -378,6 +388,11 @@ struct EnforceModeSetupView: View { defer { isProcessingToggle = false } if enabled { + guard cameraHardwareAvailable else { + print("⚠️ Cannot enable enforce mode - no camera hardware") + settingsManager.settings.enforcementMode = false + return + } print("🎛️ Enabling enforce mode...") await enforceModeService.enableEnforceMode() print("🎛️ Enforce mode enabled: \(enforceModeService.isEnforceModeEnabled)") diff --git a/Gaze/Views/Setup/LookAwaySetupView.swift b/Gaze/Views/Setup/LookAwaySetupView.swift index efd29d0..456b9e3 100644 --- a/Gaze/Views/Setup/LookAwaySetupView.swift +++ b/Gaze/Views/Setup/LookAwaySetupView.swift @@ -22,7 +22,8 @@ struct LookAwaySetupView: View { VStack(spacing: 30) { InfoBox( text: "Suggested: 20-20-20 rule", - url: "https://journals.co.za/doi/abs/10.4102/aveh.v79i1.554#:~:text=the 20/20/20 rule induces significant changes in dry eye symptoms and tear film and some limited changes for ocular surface integrity." + url: + "https://journals.co.za/doi/abs/10.4102/aveh.v79i1.554#:~:text=the 20/20/20 rule induces significant changes in dry eye symptoms and tear film and some limited changes for ocular surface integrity." ) SliderSection( @@ -34,7 +35,8 @@ struct LookAwaySetupView: View { ) }, set: { newValue in - settingsManager.settings.lookAwayTimer.intervalSeconds = (newValue.val ?? 20) * 60 + settingsManager.settings.lookAwayTimer.intervalSeconds = + (newValue.val ?? 20) * 60 } ), countdownSettings: Binding( @@ -52,22 +54,8 @@ struct LookAwaySetupView: View { type: "Look away", previewFunc: showPreviewWindow ) - - Toggle("Enable enforcement mode", isOn: $settingsManager.settings.enforcementMode) - .onChange(of: settingsManager.settings.enforcementMode) { _, newMode in - if newMode && !cameraAccess.isCameraAuthorized { - Task { - do { - try await cameraAccess.requestCameraAccess() - } catch { - failedCameraAccess = true - settingsManager.settings.enforcementMode = false - } - } - } - } } - + Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/GazeTests/Services/TimerEngineTests.swift b/GazeTests/Services/TimerEngineTests.swift index 76fcf4a..ec2e55b 100644 --- a/GazeTests/Services/TimerEngineTests.swift +++ b/GazeTests/Services/TimerEngineTests.swift @@ -15,18 +15,25 @@ final class TimerEngineTests: XCTestCase { var testEnv: TestEnvironment! var timerEngine: TimerEngine! + var systemSleepManager: SystemSleepManager! var cancellables: Set! override func setUp() async throws { testEnv = TestEnvironment(settings: .defaults) timerEngine = testEnv.container.timerEngine + systemSleepManager = SystemSleepManager( + timerEngine: timerEngine, + settingsManager: testEnv.settingsManager + ) cancellables = [] } override func tearDown() async throws { timerEngine?.stop() + systemSleepManager?.stopObserving() cancellables = nil timerEngine = nil + systemSleepManager = nil testEnv = nil } @@ -252,20 +259,20 @@ final class TimerEngineTests: XCTestCase { // MARK: - System Sleep/Wake Tests - func testHandleSystemSleep() { + func testSystemSleepManagerHandlesSleep() { timerEngine.start() let statesBefore = timerEngine.timerStates.count - timerEngine.handleSystemSleep() + systemSleepManager.handleSystemWillSleep() // States should still exist XCTAssertEqual(timerEngine.timerStates.count, statesBefore) } - func testHandleSystemWake() { + func testSystemSleepManagerHandlesWake() { timerEngine.start() - timerEngine.handleSystemSleep() - timerEngine.handleSystemWake() + systemSleepManager.handleSystemWillSleep() + systemSleepManager.handleSystemDidWake() // Should handle wake event without crashing XCTAssertGreaterThan(timerEngine.timerStates.count, 0)