diff --git a/Gaze.xcodeproj/project.pbxproj b/Gaze.xcodeproj/project.pbxproj index 251f434..e3ce3ca 100644 --- a/Gaze.xcodeproj/project.pbxproj +++ b/Gaze.xcodeproj/project.pbxproj @@ -437,7 +437,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -473,7 +473,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Gaze.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Gaze.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 726a161..b3cf5c5 100644 --- a/Gaze.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Gaze.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,24 @@ { - "originHash": "513d974fbede884a919977d3446360023f6e3239ac314f4fbd9657e80aca7560", - "pins": [ + "originHash" : "513d974fbede884a919977d3446360023f6e3239ac314f4fbd9657e80aca7560", + "pins" : [ { - "identity": "lottie-spm", - "kind": "remoteSourceControl", - "location": "https://github.com/airbnb/lottie-spm.git", - "state": { - "revision": "69faaefa7721fba9e434a52c16adf4329c9084db", - "version": "4.6.0" + "identity" : "lottie-spm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/airbnb/lottie-spm.git", + "state" : { + "revision" : "69faaefa7721fba9e434a52c16adf4329c9084db", + "version" : "4.6.0" + } + }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", + "version" : "2.8.1" } } ], - "version": 3 -} \ No newline at end of file + "version" : 3 +} diff --git a/Gaze/GazeApp.swift b/Gaze/GazeApp.swift index e7443be..c8a1dfb 100644 --- a/Gaze/GazeApp.swift +++ b/Gaze/GazeApp.swift @@ -10,9 +10,7 @@ import SwiftUI @main struct GazeApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - // Note: SettingsManager.shared is used directly here for SwiftUI view updates - // AppDelegate uses ServiceContainer for dependency injection - @StateObject private var settingsManager = SettingsManager.shared + @State private var settingsManager = SettingsManager.shared init() { // Handle test launch arguments diff --git a/Gaze/Protocols/SettingsProviding.swift b/Gaze/Protocols/SettingsProviding.swift index e652cd5..df8dd22 100644 --- a/Gaze/Protocols/SettingsProviding.swift +++ b/Gaze/Protocols/SettingsProviding.swift @@ -2,47 +2,26 @@ // SettingsProviding.swift // Gaze // -// Protocol abstraction for SettingsManager to enable dependency injection and testing. -// import Combine import Foundation -/// Protocol that defines the interface for managing application settings. -/// This abstraction allows for dependency injection and easy mocking in tests. @MainActor -protocol SettingsProviding: AnyObject, ObservableObject { - /// The current application settings +protocol SettingsProviding: AnyObject, Observable { var settings: AppSettings { get set } + var settingsPublisher: AnyPublisher { get } - /// Publisher for observing settings changes - var settingsPublisher: Published.Publisher { get } - - /// Retrieves the timer configuration for a specific timer type func timerConfiguration(for type: TimerType) -> TimerConfiguration - - /// Updates the timer configuration for a specific timer type func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) - - /// Returns all timer configurations func allTimerConfigurations() -> [TimerType: TimerConfiguration] - - /// Saves settings to persistent storage func save() - - /// Forces immediate save func saveImmediately() - - /// Loads settings from persistent storage func load() - - /// Resets settings to default values func resetToDefaults() } -/// Extension to provide the publisher for SettingsManager extension SettingsManager: SettingsProviding { - var settingsPublisher: Published.Publisher { - $settings + var settingsPublisher: AnyPublisher { + _settingsSubject.eraseToAnyPublisher() } } diff --git a/Gaze/Services/ServiceContainer.swift b/Gaze/Services/ServiceContainer.swift index 0f283a8..23bcde8 100644 --- a/Gaze/Services/ServiceContainer.swift +++ b/Gaze/Services/ServiceContainer.swift @@ -120,13 +120,18 @@ final class ServiceContainer { /// A mock settings manager for use in ServiceContainer.forTesting() /// This is a minimal implementation - use the full MockSettingsManager from tests for more features @MainActor -final class MockSettingsManager: ObservableObject, SettingsProviding { - @Published var settings: AppSettings +@Observable +final class MockSettingsManager: SettingsProviding { + var settings: AppSettings - var settingsPublisher: Published.Publisher { - $settings + @ObservationIgnored + private let _settingsSubject: CurrentValueSubject + + var settingsPublisher: AnyPublisher { + _settingsSubject.eraseToAnyPublisher() } + @ObservationIgnored private let timerConfigKeyPaths: [TimerType: WritableKeyPath] = [ .lookAway: \.lookAwayTimer, .blink: \.blinkTimer, @@ -135,6 +140,7 @@ final class MockSettingsManager: ObservableObject, SettingsProviding { init(settings: AppSettings = .defaults) { self.settings = settings + self._settingsSubject = CurrentValueSubject(settings) } func timerConfiguration(for type: TimerType) -> TimerConfiguration { @@ -149,6 +155,7 @@ final class MockSettingsManager: ObservableObject, SettingsProviding { preconditionFailure("Unknown timer type: \(type)") } settings[keyPath: keyPath] = configuration + _settingsSubject.send(settings) } func allTimerConfigurations() -> [TimerType: TimerConfiguration] { @@ -159,8 +166,11 @@ final class MockSettingsManager: ObservableObject, SettingsProviding { return configs } - func save() {} - func saveImmediately() {} + func save() { _settingsSubject.send(settings) } + func saveImmediately() { _settingsSubject.send(settings) } func load() {} - func resetToDefaults() { settings = .defaults } + func resetToDefaults() { + settings = .defaults + _settingsSubject.send(settings) + } } diff --git a/Gaze/Services/SettingsManager.swift b/Gaze/Services/SettingsManager.swift index c4e65a4..bb2f6be 100644 --- a/Gaze/Services/SettingsManager.swift +++ b/Gaze/Services/SettingsManager.swift @@ -7,35 +7,44 @@ import Combine import Foundation +import Observation @MainActor -class SettingsManager: ObservableObject { +@Observable +final class SettingsManager { static let shared = SettingsManager() - @Published var settings: AppSettings + + var settings: AppSettings { + didSet { _settingsSubject.send(settings) } + } + + @ObservationIgnored + let _settingsSubject = CurrentValueSubject(.defaults) + + @ObservationIgnored private let userDefaults = UserDefaults.standard + + @ObservationIgnored private let settingsKey = "gazeAppSettings" + + @ObservationIgnored private var saveCancellable: AnyCancellable? - private let timerConfigKeyPaths: [TimerType: WritableKeyPath] = - [ - .lookAway: \.lookAwayTimer, - .blink: \.blinkTimer, - .posture: \.postureTimer, - ] + @ObservationIgnored + private let timerConfigKeyPaths: [TimerType: WritableKeyPath] = [ + .lookAway: \.lookAwayTimer, + .blink: \.blinkTimer, + .posture: \.postureTimer, + ] private init() { self.settings = Self.loadSettings() + _settingsSubject.send(settings) setupDebouncedSave() } - deinit { - saveCancellable?.cancel() - // Final save is called by AppDelegate.applicationWillTerminate - } - private func setupDebouncedSave() { - saveCancellable = - $settings + saveCancellable = _settingsSubject .debounce(for: .milliseconds(500), scheduler: RunLoop.main) .sink { [weak self] _ in self?.save() @@ -46,30 +55,22 @@ class SettingsManager: ObservableObject { guard let data = UserDefaults.standard.data(forKey: "gazeAppSettings") else { return .defaults } - do { - let settings = try JSONDecoder().decode(AppSettings.self, from: data) - return settings + return try JSONDecoder().decode(AppSettings.self, from: data) } catch { return .defaults } } - /// Saves settings to UserDefaults. - /// Note: Settings are automatically saved via debouncing (500ms delay) when the `settings` property changes. - /// This method is also called explicitly during app termination to ensure final state is persisted. func save() { do { let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted let data = try encoder.encode(settings) userDefaults.set(data, forKey: settingsKey) - } catch { - } + } catch {} } - /// Forces immediate save and ensures UserDefaults are persisted to disk. - /// Use this for critical save points like app termination or system sleep. func saveImmediately() { save() } @@ -96,7 +97,6 @@ class SettingsManager: ObservableObject { settings[keyPath: keyPath] = configuration } - /// Returns all timer configurations as a dictionary func allTimerConfigurations() -> [TimerType: TimerConfiguration] { var configs: [TimerType: TimerConfiguration] = [:] for (type, keyPath) in timerConfigKeyPaths { @@ -104,15 +104,4 @@ class SettingsManager: ObservableObject { } return configs } - - /// Validates that all timer types have configuration mappings - private func validateTimerConfigMappings() { - let allTypes = Set(TimerType.allCases) - let mappedTypes = Set(timerConfigKeyPaths.keys) - - let missing = allTypes.subtracting(mappedTypes) - if !missing.isEmpty { - preconditionFailure("Missing timer configuration mappings for: \(missing)") - } - } } diff --git a/Gaze/Views/Components/PreviewWindowHelper.swift b/Gaze/Views/Components/PreviewWindowHelper.swift new file mode 100644 index 0000000..1ddbb51 --- /dev/null +++ b/Gaze/Views/Components/PreviewWindowHelper.swift @@ -0,0 +1,35 @@ +// +// PreviewWindowHelper.swift +// Gaze +// +// Created by Mike Freno on 1/15/26. +// + +import AppKit +import SwiftUI + +enum PreviewWindowHelper { + static func showPreview( + on screen: NSScreen, + content: Content + ) -> NSWindowController { + let panel = NSPanel( + contentRect: screen.frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.level = .screenSaver + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = false + panel.ignoresMouseEvents = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.contentView = NSHostingView(rootView: content) + panel.setFrame(screen.frame, display: true) + + let controller = NSWindowController(window: panel) + controller.showWindow(nil) + return controller + } +} diff --git a/Gaze/Views/Components/SetupHeader.swift b/Gaze/Views/Components/SetupHeader.swift new file mode 100644 index 0000000..0bc23b1 --- /dev/null +++ b/Gaze/Views/Components/SetupHeader.swift @@ -0,0 +1,34 @@ +// +// SetupHeader.swift +// Gaze +// +// Created by Mike Freno on 1/15/26. +// + +import SwiftUI + +struct SetupHeader: View { + let icon: String + let title: String + let color: Color + + var body: some View { + VStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 60)) + .foregroundColor(color) + Text(title) + .font(.system(size: 28, weight: .bold)) + } + .padding(.top, 20) + .padding(.bottom, 30) + } +} + +#Preview("SetupHeader") { + VStack(spacing: 40) { + SetupHeader(icon: "eye.fill", title: "Look Away Reminder", color: .accentColor) + SetupHeader(icon: "eye.circle", title: "Blink Reminder", color: .green) + SetupHeader(icon: "figure.stand", title: "Posture Reminder", color: .orange) + } +} diff --git a/Gaze/Views/Containers/OnboardingContainerView.swift b/Gaze/Views/Containers/OnboardingContainerView.swift index 1b004dd..a4b6939 100644 --- a/Gaze/Views/Containers/OnboardingContainerView.swift +++ b/Gaze/Views/Containers/OnboardingContainerView.swift @@ -1,3 +1,10 @@ +// +// OnboardingContainerView.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + import AppKit import SwiftUI @@ -27,9 +34,7 @@ final class OnboardingWindowPresenter { private var closeObserver: NSObjectProtocol? func show(settingsManager: SettingsManager) { - if activateIfPresent() { - return - } + if activateIfPresent() { return } createWindow(settingsManager: settingsManager) } @@ -39,26 +44,16 @@ final class OnboardingWindowPresenter { windowController = nil return false } - - // Ensure the window is brought to front and focused properly for menu bar apps window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) - - // Additional focus handling for menu bar applications - if let window = windowController?.window { - window.makeMain() - } - + window.makeMain() return true } func close() { windowController?.close() windowController = nil - if let closeObserver { - NotificationCenter.default.removeObserver(closeObserver) - self.closeObserver = nil - } + removeCloseObserver() } private func createWindow(settingsManager: SettingsManager) { @@ -85,34 +80,29 @@ final class OnboardingWindowPresenter { windowController = controller - closeObserver.map(NotificationCenter.default.removeObserver) + removeCloseObserver() closeObserver = NotificationCenter.default.addObserver( forName: NSWindow.willCloseNotification, object: window, queue: .main ) { [weak self] _ in self?.windowController = nil - if let closeObserver = self?.closeObserver { - NotificationCenter.default.removeObserver(closeObserver) - } - self?.closeObserver = nil - - // Notify AppDelegate that onboarding window closed + self?.removeCloseObserver() NotificationCenter.default.post(name: Notification.Name("OnboardingWindowDidClose"), object: nil) } } - deinit { - if let closeObserver { - NotificationCenter.default.removeObserver(closeObserver) + private func removeCloseObserver() { + if let observer = closeObserver { + NotificationCenter.default.removeObserver(observer) + closeObserver = nil } } } struct OnboardingContainerView: View { - @ObservedObject var settingsManager: SettingsManager + @Bindable var settingsManager: SettingsManager @State private var currentPage = 0 - @Environment(\.dismiss) private var dismiss var body: some View { ZStack { @@ -122,127 +112,87 @@ struct OnboardingContainerView: View { TabView(selection: $currentPage) { WelcomeView() .tag(0) - .tabItem { - Image(systemName: "hand.wave.fill") - } + .tabItem { Image(systemName: "hand.wave.fill") } LookAwaySetupView(settingsManager: settingsManager) .tag(1) - .tabItem { - Image(systemName: "eye.fill") - } + .tabItem { Image(systemName: "eye.fill") } BlinkSetupView(settingsManager: settingsManager) .tag(2) - .tabItem { - Image(systemName: "eye.circle.fill") - } + .tabItem { Image(systemName: "eye.circle.fill") } PostureSetupView(settingsManager: settingsManager) .tag(3) - .tabItem { - Image(systemName: "figure.stand") - } + .tabItem { Image(systemName: "figure.stand") } - GeneralSetupView( - settingsManager: settingsManager, - isOnboarding: true - ) - .tag(4) - .tabItem { - Image(systemName: "gearshape.fill") - } + GeneralSetupView(settingsManager: settingsManager, isOnboarding: true) + .tag(4) + .tabItem { Image(systemName: "gearshape.fill") } CompletionView() .tag(5) - .tabItem { - Image(systemName: "checkmark.circle.fill") - } + .tabItem { Image(systemName: "checkmark.circle.fill") } } .tabViewStyle(.automatic) - if currentPage >= 0 { - HStack(spacing: 12) { - if currentPage > 0 { - Button(action: { currentPage -= 1 }) { - HStack { - Image(systemName: "chevron.left") - Text("Back") - } - .font(.headline) - .frame( - minWidth: 100, maxWidth: .infinity, minHeight: 44, - maxHeight: 44, alignment: .center - ) - .foregroundColor(.primary) - .contentShape(RoundedRectangle(cornerRadius: 10)) - } - .buttonStyle(.plain) - .glassEffectIfAvailable( - GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10)) - } - - Button(action: { - if currentPage == 5 { - completeOnboarding() - } else { - currentPage += 1 - } - }) { - Text( - currentPage == 0 - ? "Let's Get Started" - : currentPage == 5 ? "Get Started" : "Continue" - ) - .font(.headline) - .frame( - minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44, - alignment: .center - ) - .foregroundColor(.white) - .contentShape(RoundedRectangle(cornerRadius: 10)) - } - .buttonStyle(.plain) - .glassEffectIfAvailable( - GlassStyle.regular.tint(currentPage == 5 ? .green : .accentColor) - .interactive(), - in: .rect(cornerRadius: 10)) - } - .padding(.horizontal, 40) - .padding(.bottom, 20) - } + navigationButtons } } #if APPSTORE - .frame( - minWidth: 1000, - minHeight: 700 - ) + .frame(minWidth: 1000, minHeight: 700) #else - .frame( - minWidth: 1000, - minHeight: 900 - ) + .frame(minWidth: 1000, minHeight: 900) #endif } - private func completeOnboarding() { - // Mark onboarding as complete - settings are already being updated in real-time - settingsManager.settings.hasCompletedOnboarding = true - - dismiss() - - DispatchQueue.main.asyncAfter(deadline: .now()) { - if let menuBarWindow = NSApp.windows.first(where: { - $0.className.contains("MenuBarExtra") || $0.className.contains("StatusBar") - }), - let statusItem = menuBarWindow.value(forKey: "statusItem") as? NSStatusItem - { - statusItem.button?.performClick(nil) + @ViewBuilder + private var navigationButtons: some View { + HStack(spacing: 12) { + if currentPage > 0 { + Button(action: { currentPage -= 1 }) { + HStack { + Image(systemName: "chevron.left") + Text("Back") + } + .font(.headline) + .frame(minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44) + .foregroundColor(.primary) + .contentShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + .glassEffectIfAvailable(GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10)) } + + Button(action: { + if currentPage == 5 { + completeOnboarding() + } else { + currentPage += 1 + } + }) { + Text(currentPage == 0 ? "Let's Get Started" : currentPage == 5 ? "Get Started" : "Continue") + .font(.headline) + .frame(minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44) + .foregroundColor(.white) + .contentShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + .glassEffectIfAvailable( + GlassStyle.regular.tint(currentPage == 5 ? .green : .accentColor).interactive(), + in: .rect(cornerRadius: 10) + ) } + .padding(.horizontal, 40) + .padding(.bottom, 20) + } + + private func completeOnboarding() { + settingsManager.settings.hasCompletedOnboarding = true + OnboardingWindowPresenter.shared.close() } } + #Preview("Onboarding Container") { OnboardingContainerView(settingsManager: SettingsManager.shared) } diff --git a/Gaze/Views/Containers/SettingsWindowView.swift b/Gaze/Views/Containers/SettingsWindowView.swift index 5e654aa..33e1f0c 100644 --- a/Gaze/Views/Containers/SettingsWindowView.swift +++ b/Gaze/Views/Containers/SettingsWindowView.swift @@ -15,9 +15,7 @@ final class SettingsWindowPresenter { private var closeObserver: NSObjectProtocol? func show(settingsManager: SettingsManager, initialTab: Int = 0) { - if focusExistingWindow(tab: initialTab) { - return - } + if focusExistingWindow(tab: initialTab) { return } createWindow(settingsManager: settingsManager, initialTab: initialTab) } @@ -67,15 +65,12 @@ final class SettingsWindowPresenter { window.setFrameAutosaveName("SettingsWindow") window.isReleasedWhenClosed = false - let contentView = SettingsWindowView( - settingsManager: settingsManager, - initialTab: initialTab + window.contentView = NSHostingView( + rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab) ) - window.contentView = NSHostingView(rootView: contentView) let controller = NSWindowController(window: window) controller.showWindow(nil) - window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) @@ -90,32 +85,21 @@ final class SettingsWindowPresenter { Task { @MainActor [weak self] in self?.windowController = nil self?.removeCloseObserver() - - // Notify AppDelegate that settings window closed NotificationCenter.default.post(name: Notification.Name("SettingsWindowDidClose"), object: nil) } } } - @MainActor private func removeCloseObserver() { - if let closeObserver { - NotificationCenter.default.removeObserver(closeObserver) - self.closeObserver = nil - } - } - - deinit { - // Capture observer locally to avoid actor isolation issues - // NotificationCenter.removeObserver is thread-safe if let observer = closeObserver { NotificationCenter.default.removeObserver(observer) + closeObserver = nil } } } struct SettingsWindowView: View { - @ObservedObject var settingsManager: SettingsManager + @Bindable var settingsManager: SettingsManager @State private var selectedSection: SettingsSection init(settingsManager: SettingsManager, initialTab: Int = 0) { @@ -140,42 +124,31 @@ struct SettingsWindowView: View { ScrollView { detailView(for: selectedSection) } - }.onReceive( - NotificationCenter.default.publisher( - for: Notification.Name("SwitchToSettingsTab")) - ) { notification in + } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("SwitchToSettingsTab"))) { notification in if let tab = notification.object as? Int, - let section = SettingsSection(rawValue: tab) - { + let section = SettingsSection(rawValue: tab) { selectedSection = section } } #if DEBUG - Divider() - - HStack { - Button("Retrigger Onboarding") { - retriggerOnboarding() - } - .buttonStyle(.bordered) - - Spacer() + Divider() + HStack { + Button("Retrigger Onboarding") { + retriggerOnboarding() } - .padding() + .buttonStyle(.bordered) + Spacer() + } + .padding() #endif } } #if APPSTORE - .frame( - minWidth: 1000, - minHeight: 700 - ) + .frame(minWidth: 1000, minHeight: 700) #else - .frame( - minWidth: 1000, - minHeight: 900 - ) + .frame(minWidth: 1000, minHeight: 900) #endif } @@ -183,10 +156,7 @@ struct SettingsWindowView: View { private func detailView(for section: SettingsSection) -> some View { switch section { case .general: - GeneralSetupView( - settingsManager: settingsManager, - isOnboarding: false - ) + GeneralSetupView(settingsManager: settingsManager, isOnboarding: false) case .lookAway: LookAwaySetupView(settingsManager: settingsManager) case .blink: @@ -208,13 +178,12 @@ struct SettingsWindowView: View { } #if DEBUG - private func retriggerOnboarding() { - SettingsWindowPresenter.shared.close() - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - settingsManager.settings.hasCompletedOnboarding = false - } + private func retriggerOnboarding() { + SettingsWindowPresenter.shared.close() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + settingsManager.settings.hasCompletedOnboarding = false } + } #endif } diff --git a/Gaze/Views/MenuBar/MenuBarContentView.swift b/Gaze/Views/MenuBar/MenuBarContentView.swift index 6fe7265..1494cf8 100644 --- a/Gaze/Views/MenuBar/MenuBarContentView.swift +++ b/Gaze/Views/MenuBar/MenuBarContentView.swift @@ -7,10 +7,9 @@ import SwiftUI -// Wrapper to properly observe AppDelegate changes in MenuBarExtra struct MenuBarContentWrapper: View { @ObservedObject var appDelegate: AppDelegate - @ObservedObject var settingsManager: SettingsManager + @Bindable var settingsManager: SettingsManager var onQuit: () -> Void var onOpenSettings: () -> Void var onOpenSettingsTab: (Int) -> Void @@ -71,7 +70,7 @@ struct MenuBarHoverButtonStyle: ButtonStyle { struct MenuBarContentView: View { var timerEngine: TimerEngine? - @ObservedObject var settingsManager: SettingsManager + @Bindable var settingsManager: SettingsManager @Environment(\.dismiss) private var dismiss var onQuit: () -> Void var onOpenSettings: () -> Void @@ -251,7 +250,7 @@ struct MenuBarContentView: View { struct TimerStatusRowWithIndividualControls: View { let identifier: TimerIdentifier @ObservedObject var timerEngine: TimerEngine - @ObservedObject var settingsManager: SettingsManager + @Bindable var settingsManager: SettingsManager var onSkip: () -> Void var onDevTrigger: (() -> Void)? = nil var onTogglePause: (Bool) -> Void diff --git a/Gaze/Views/Setup/BlinkSetupView.swift b/Gaze/Views/Setup/BlinkSetupView.swift index 8a55faf..291dd11 100644 --- a/Gaze/Views/Setup/BlinkSetupView.swift +++ b/Gaze/Views/Setup/BlinkSetupView.swift @@ -9,58 +9,36 @@ import AppKit import SwiftUI struct BlinkSetupView: View { - @ObservedObject var settingsManager: SettingsManager + @Bindable var settingsManager: SettingsManager @State private var previewWindowController: NSWindowController? var body: some View { VStack(spacing: 0) { - // Fixed header section - VStack(spacing: 16) { - Image(systemName: "eye.circle") - .font(.system(size: 60)) - .foregroundColor(.green) + SetupHeader(icon: "eye.circle", title: "Blink Reminder", color: .green) - Text("Blink Reminder") - .font(.system(size: 28, weight: .bold)) - } - .padding(.top, 20) - .padding(.bottom, 30) - - // Vertically centered content Spacer() VStack(spacing: 30) { HStack(spacing: 12) { Button(action: { - if let url = URL( - string: - "https://www.aao.org/eye-health/tips-prevention/computer-usage#:~:text=Humans normally blink about 15 times in one minute. However, studies show that we only blink about 5 to 7 times in a minute while using computers and other digital screen devices." - ) { - #if os(iOS) - UIApplication.shared.open(url) - #elseif os(macOS) - NSWorkspace.shared.open(url) - #endif + if let url = URL(string: "https://www.aao.org/eye-health/tips-prevention/computer-usage#:~:text=Humans normally blink about 15 times in one minute. However, studies show that we only blink about 5 to 7 times in a minute while using computers and other digital screen devices.") { + NSWorkspace.shared.open(url) } }) { Image(systemName: "info.circle") .foregroundColor(.white) - }.buttonStyle(.plain) - Text( - "We blink much less when focusing on screens. Regular blink reminders help prevent dry eyes." - ) - .font(.headline) - .foregroundColor(.white) + } + .buttonStyle(.plain) + + Text("We blink much less when focusing on screens. Regular blink reminders help prevent dry eyes.") + .font(.headline) + .foregroundColor(.white) } .padding() - .glassEffectIfAvailable( - GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8)) + .glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8)) VStack(alignment: .leading, spacing: 20) { - Toggle("Enable Blink Reminders", isOn: Binding( - get: { settingsManager.settings.blinkTimer.enabled }, - set: { settingsManager.settings.blinkTimer.enabled = $0 } - )) + Toggle("Enable Blink Reminders", isOn: $settingsManager.settings.blinkTimer.enabled) .font(.headline) if settingsManager.settings.blinkTimer.enabled { @@ -74,7 +52,10 @@ struct BlinkSetupView: View { value: Binding( get: { Double(settingsManager.settings.blinkTimer.intervalSeconds / 60) }, set: { settingsManager.settings.blinkTimer.intervalSeconds = Int($0) * 60 } - ), in: 1...20, step: 1) + ), + in: 1...20, + step: 1 + ) Text("\(settingsManager.settings.blinkTimer.intervalSeconds / 60) min") .frame(width: 60, alignment: .trailing) @@ -87,23 +68,16 @@ struct BlinkSetupView: View { .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) if settingsManager.settings.blinkTimer.enabled { - Text( - "You will be subtly reminded every \(settingsManager.settings.blinkTimer.intervalSeconds / 60) minutes to blink" - ) - .font(.subheadline) - .foregroundColor(.secondary) + Text("You will be subtly reminded every \(settingsManager.settings.blinkTimer.intervalSeconds / 60) minutes to blink") + .font(.subheadline) + .foregroundColor(.secondary) } else { - Text( - "Blink reminders are currently disabled." - ) - .font(.caption) - .foregroundColor(.secondary) + Text("Blink reminders are currently disabled.") + .font(.caption) + .foregroundColor(.secondary) } - // Preview button - Button(action: { - showPreviewWindow() - }) { + Button(action: showPreviewWindow) { HStack(spacing: 8) { Image(systemName: "eye") .foregroundColor(.white) @@ -116,9 +90,7 @@ struct BlinkSetupView: View { .contentShape(RoundedRectangle(cornerRadius: 10)) } .buttonStyle(.plain) - .glassEffectIfAvailable( - GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10) - ) + .glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10)) } Spacer() @@ -130,35 +102,12 @@ struct BlinkSetupView: View { private func showPreviewWindow() { guard let screen = NSScreen.main else { return } - - let window = NSWindow( - contentRect: screen.frame, - styleMask: [.borderless, .fullSizeContentView], - backing: .buffered, - defer: false + previewWindowController = PreviewWindowHelper.showPreview( + on: screen, + content: BlinkReminderView(sizePercentage: settingsManager.settings.subtleReminderSize.percentage) { [weak previewWindowController] in + previewWindowController?.window?.close() + } ) - - window.level = .floating - window.isOpaque = false - window.backgroundColor = .clear - window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - window.acceptsMouseMovedEvents = true - - let contentView = BlinkReminderView( - sizePercentage: settingsManager.settings.subtleReminderSize.percentage - ) { - [weak window] in - window?.close() - } - - window.contentView = NSHostingView(rootView: contentView) - window.makeFirstResponder(window.contentView) - - let windowController = NSWindowController(window: window) - windowController.showWindow(nil) - window.makeKeyAndOrderFront(nil) - - previewWindowController = windowController } } diff --git a/Gaze/Views/Setup/EnforceModeSetupView.swift b/Gaze/Views/Setup/EnforceModeSetupView.swift index 9ab6d60..6ae1080 100644 --- a/Gaze/Views/Setup/EnforceModeSetupView.swift +++ b/Gaze/Views/Setup/EnforceModeSetupView.swift @@ -10,7 +10,7 @@ import SwiftUI import Foundation struct EnforceModeSetupView: View { - @ObservedObject var settingsManager: SettingsManager + @Bindable var settingsManager: SettingsManager @ObservedObject var cameraService = CameraAccessService.shared @ObservedObject var eyeTrackingService = EyeTrackingService.shared @ObservedObject var enforceModeService = EnforceModeService.shared @@ -26,15 +26,7 @@ struct EnforceModeSetupView: View { var body: some View { VStack(spacing: 0) { - VStack(spacing: 16) { - Image(systemName: "video.fill") - .font(.system(size: 60)) - .foregroundColor(.accentColor) - Text("Enforce Mode") - .font(.system(size: 28, weight: .bold)) - } - .padding(.top, 20) - .padding(.bottom, 30) + SetupHeader(icon: "video.fill", title: "Enforce Mode", color: .accentColor) Spacer() diff --git a/Gaze/Views/Setup/GeneralSetupView.swift b/Gaze/Views/Setup/GeneralSetupView.swift index fb75a72..eea1401 100644 --- a/Gaze/Views/Setup/GeneralSetupView.swift +++ b/Gaze/Views/Setup/GeneralSetupView.swift @@ -8,22 +8,13 @@ import SwiftUI struct GeneralSetupView: View { - @ObservedObject var settingsManager: SettingsManager - @ObservedObject var updateManager = UpdateManager.shared + @Bindable var settingsManager: SettingsManager + var updateManager = UpdateManager.shared var isOnboarding: Bool = true var body: some View { VStack(spacing: 0) { - // Fixed header section - VStack(spacing: 16) { - Image(systemName: "gearshape.fill") - .font(.system(size: 60)) - .foregroundColor(.accentColor) - Text(isOnboarding ? "Final Settings" : "General Settings") - .font(.system(size: 28, weight: .bold)) - } - .padding(.top, 20) - .padding(.bottom, 30) + SetupHeader(icon: "gearshape.fill", title: isOnboarding ? "Final Settings" : "General Settings", color: .accentColor) Spacer() VStack(spacing: 30) { @@ -33,183 +24,16 @@ struct GeneralSetupView: View { .multilineTextAlignment(.center) VStack(spacing: 20) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Launch at Login") - .font(.headline) - Text("Start Gaze automatically when you log in") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Toggle( - "", - isOn: Binding( - get: { settingsManager.settings.launchAtLogin }, - set: { settingsManager.settings.launchAtLogin = $0 } - ) - ) - .labelsHidden() - .onChange(of: settingsManager.settings.launchAtLogin) { isEnabled in - applyLaunchAtLoginSetting(enabled: isEnabled) - } - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - + launchAtLoginToggle + #if !APPSTORE - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Software Updates") - .font(.headline) - - if let lastCheck = updateManager.lastUpdateCheckDate { - Text("Last checked: \(lastCheck, style: .relative)") - .font(.caption) - .foregroundColor(.secondary) - .italic() - } else { - Text("Never checked for updates") - .font(.caption) - .foregroundColor(.secondary) - .italic() - } - } - - Spacer() - - Button("Check for Updates Now") { - updateManager.checkForUpdates() - } - .buttonStyle(.bordered) - - Toggle( - "Automatically check for updates", - isOn: $updateManager.automaticallyChecksForUpdates - ) - .labelsHidden() - .help("Check for new versions of Gaze in the background") - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + softwareUpdatesSection #endif - VStack(alignment: .leading, spacing: 12) { - Text("Subtle Reminder Size") - .font(.headline) - - Text("Adjust the size of blink and posture reminders") - .font(.caption) - .foregroundColor(.secondary) - - HStack(spacing: 12) { - ForEach(ReminderSize.allCases, id: \.self) { size in - Button(action: { - settingsManager.settings.subtleReminderSize = size - }) { - VStack(spacing: 8) { - Circle() - .fill( - settingsManager.settings.subtleReminderSize == size - ? Color.accentColor - : Color.secondary.opacity(0.3) - ) - .frame( - width: iconSize(for: size), - height: iconSize(for: size)) - - Text(size.displayName) - .font(.caption) - .fontWeight( - settingsManager.settings.subtleReminderSize == size - ? .semibold : .regular - ) - .foregroundColor( - settingsManager.settings.subtleReminderSize == size - ? .primary : .secondary) - } - .frame(maxWidth: .infinity, minHeight: 60) - .padding(.vertical, 12) - } - .glassEffectIfAvailable( - settingsManager.settings.subtleReminderSize == size - ? GlassStyle.regular.tint(.accentColor.opacity(0.3)) - : GlassStyle.regular, - in: .rect(cornerRadius: 10) - ) - } - } - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + subtleReminderSizeSection #if !APPSTORE - VStack(spacing: 12) { - Text("Support & Contribute") - .font(.headline) - .frame(maxWidth: .infinity, alignment: .leading) - - // GitHub Link - Button(action: { - if let url = URL(string: "https://github.com/mikefreno/Gaze") { - NSWorkspace.shared.open(url) - } - }) { - HStack { - Image(systemName: "chevron.left.forwardslash.chevron.right") - .font(.title3) - VStack(alignment: .leading, spacing: 2) { - Text("View on GitHub") - .font(.subheadline) - .fontWeight(.semibold) - Text("Star the repo, report issues, contribute") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption) - } - .padding() - .frame(maxWidth: .infinity) - .contentShape(RoundedRectangle(cornerRadius: 10)) - } - .buttonStyle(.plain) - .glassEffectIfAvailable( - GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10)) - - Button(action: { - if let url = URL(string: "https://buymeacoffee.com/mikefreno") { - NSWorkspace.shared.open(url) - } - }) { - HStack { - Image(systemName: "cup.and.saucer.fill") - .font(.title3) - .foregroundColor(.brown) - VStack(alignment: .leading, spacing: 2) { - Text("Buy Me a Coffee") - .font(.subheadline) - .fontWeight(.semibold) - Text("Support development of Gaze") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption) - } - .padding() - .frame(maxWidth: .infinity) - .cornerRadius(10) - .contentShape(RoundedRectangle(cornerRadius: 10)) - } - .buttonStyle(.plain) - .glassEffectIfAvailable( - GlassStyle.regular.tint(.orange).interactive(), - in: .rect(cornerRadius: 10)) - } - .padding() + supportSection #endif } } @@ -220,6 +44,131 @@ struct GeneralSetupView: View { .background(.clear) } + private var launchAtLoginToggle: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Launch at Login") + .font(.headline) + Text("Start Gaze automatically when you log in") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Toggle("", isOn: $settingsManager.settings.launchAtLogin) + .labelsHidden() + .onChange(of: settingsManager.settings.launchAtLogin) { _, isEnabled in + applyLaunchAtLoginSetting(enabled: isEnabled) + } + } + .padding() + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + } + + #if !APPSTORE + private var softwareUpdatesSection: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Software Updates") + .font(.headline) + + if let lastCheck = updateManager.lastUpdateCheckDate { + Text("Last checked: \(lastCheck, style: .relative)") + .font(.caption) + .foregroundColor(.secondary) + .italic() + } else { + Text("Never checked for updates") + .font(.caption) + .foregroundColor(.secondary) + .italic() + } + } + + Spacer() + + Button("Check for Updates Now") { + updateManager.checkForUpdates() + } + .buttonStyle(.bordered) + + Toggle("Automatically check for updates", isOn: Binding( + get: { updateManager.automaticallyChecksForUpdates }, + set: { updateManager.automaticallyChecksForUpdates = $0 } + )) + .labelsHidden() + .help("Check for new versions of Gaze in the background") + } + .padding() + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + } + #endif + + private var subtleReminderSizeSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Subtle Reminder Size") + .font(.headline) + + Text("Adjust the size of blink and posture reminders") + .font(.caption) + .foregroundColor(.secondary) + + HStack(spacing: 12) { + ForEach(ReminderSize.allCases, id: \.self) { size in + Button(action: { settingsManager.settings.subtleReminderSize = size }) { + VStack(spacing: 8) { + Circle() + .fill(settingsManager.settings.subtleReminderSize == size ? Color.accentColor : Color.secondary.opacity(0.3)) + .frame(width: iconSize(for: size), height: iconSize(for: size)) + + Text(size.displayName) + .font(.caption) + .fontWeight(settingsManager.settings.subtleReminderSize == size ? .semibold : .regular) + .foregroundColor(settingsManager.settings.subtleReminderSize == size ? .primary : .secondary) + } + .frame(maxWidth: .infinity, minHeight: 60) + .padding(.vertical, 12) + } + .glassEffectIfAvailable( + settingsManager.settings.subtleReminderSize == size + ? GlassStyle.regular.tint(.accentColor.opacity(0.3)) + : GlassStyle.regular, + in: .rect(cornerRadius: 10) + ) + } + } + } + .padding() + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + } + + #if !APPSTORE + private var supportSection: some View { + VStack(spacing: 12) { + Text("Support & Contribute") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + + ExternalLinkButton( + icon: "chevron.left.forwardslash.chevron.right", + title: "View on GitHub", + subtitle: "Star the repo, report issues, contribute", + url: "https://github.com/mikefreno/Gaze", + tint: nil + ) + + ExternalLinkButton( + icon: "cup.and.saucer.fill", + iconColor: .brown, + title: "Buy Me a Coffee", + subtitle: "Support development of Gaze", + url: "https://buymeacoffee.com/mikefreno", + tint: .orange + ) + } + .padding() + } + #endif + private func applyLaunchAtLoginSetting(enabled: Bool) { do { if enabled { @@ -227,9 +176,7 @@ struct GeneralSetupView: View { } else { try LaunchAtLoginManager.disable() } - } catch { - //TODO: see what can be done here - } + } catch {} } private func iconSize(for size: ReminderSize) -> CGFloat { @@ -241,9 +188,48 @@ struct GeneralSetupView: View { } } -#Preview("Settings Onboarding") { - GeneralSetupView( - settingsManager: SettingsManager.shared, - isOnboarding: true - ) +struct ExternalLinkButton: View { + let icon: String + var iconColor: Color = .primary + let title: String + let subtitle: String + let url: String + let tint: Color? + + var body: some View { + Button(action: { + if let url = URL(string: url) { + NSWorkspace.shared.open(url) + } + }) { + HStack { + Image(systemName: icon) + .font(.title3) + .foregroundColor(iconColor) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline) + .fontWeight(.semibold) + Text(subtitle) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + } + .padding() + .frame(maxWidth: .infinity) + .contentShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + .glassEffectIfAvailable( + tint != nil ? GlassStyle.regular.tint(tint!).interactive() : GlassStyle.regular.interactive(), + in: .rect(cornerRadius: 10) + ) + } +} + +#Preview("Settings Onboarding") { + GeneralSetupView(settingsManager: SettingsManager.shared, isOnboarding: true) } diff --git a/Gaze/Views/Setup/LookAwaySetupView.swift b/Gaze/Views/Setup/LookAwaySetupView.swift index 1166c9f..1645ef3 100644 --- a/Gaze/Views/Setup/LookAwaySetupView.swift +++ b/Gaze/Views/Setup/LookAwaySetupView.swift @@ -8,38 +8,22 @@ import AppKit import SwiftUI -#if os(iOS) - import UIKit -#endif - struct LookAwaySetupView: View { - @ObservedObject var settingsManager: SettingsManager + @Bindable var settingsManager: SettingsManager @State private var previewWindowController: NSWindowController? - @ObservedObject var cameraAccess = CameraAccessService.shared + var cameraAccess = CameraAccessService.shared @State private var failedCameraAccess = false var body: some View { VStack(spacing: 0) { - // Fixed header section - VStack(spacing: 16) { - Image(systemName: "eye.fill") - .font(.system(size: 60)) - .foregroundColor(.accentColor) - - Text("Look Away Reminder") - .font(.system(size: 28, weight: .bold)) - } - .padding(.top, 20) - .padding(.bottom, 30) + SetupHeader(icon: "eye.fill", title: "Look Away Reminder", color: .accentColor) Spacer() 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( @@ -51,8 +35,7 @@ struct LookAwaySetupView: View { ) }, set: { newValue in - settingsManager.settings.lookAwayTimer.intervalSeconds = - (newValue.val ?? 20) * 60 + settingsManager.settings.lookAwayTimer.intervalSeconds = (newValue.val ?? 20) * 60 } ), countdownSettings: Binding( @@ -66,41 +49,26 @@ struct LookAwaySetupView: View { settingsManager.settings.lookAwayCountdownSeconds = newValue.val ?? 20 } ), - enabled: Binding( - get: { settingsManager.settings.lookAwayTimer.enabled }, - set: { settingsManager.settings.lookAwayTimer.enabled = $0 } - ), + enabled: $settingsManager.settings.lookAwayTimer.enabled, type: "Look away", previewFunc: showPreviewWindow ) - Toggle( - "Enable enforcement mode", - isOn: Binding( - get: { settingsManager.settings.enforcementMode }, - set: { settingsManager.settings.enforcementMode = $0 } - ) - ) - .onChange( - of: settingsManager.settings.enforcementMode, - ) { newMode in - if newMode && !cameraAccess.isCameraAuthorized { - Task { - do { - try await cameraAccess.requestCameraAccess() - } catch { - failedCameraAccess = true - settingsManager.settings.enforcementMode = false + + 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 + } } } } - - } } - #if failedCameraAccess - Text( - "Camera access denied. Please enable camera access in System Settings if you want to use enforcement mode." - ) - #endif + Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -110,35 +78,12 @@ struct LookAwaySetupView: View { private func showPreviewWindow() { guard let screen = NSScreen.main else { return } - - let window = NSWindow( - contentRect: screen.frame, - styleMask: [.borderless, .fullSizeContentView], - backing: .buffered, - defer: false + previewWindowController = PreviewWindowHelper.showPreview( + on: screen, + content: LookAwayReminderView(countdownSeconds: settingsManager.settings.lookAwayCountdownSeconds) { [weak previewWindowController] in + previewWindowController?.window?.close() + } ) - - window.level = .floating - window.isOpaque = false - window.backgroundColor = .clear - window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - window.acceptsMouseMovedEvents = true - - let contentView = LookAwayReminderView( - countdownSeconds: settingsManager.settings.lookAwayCountdownSeconds - ) { - [weak window] in - window?.close() - } - - window.contentView = NSHostingView(rootView: contentView) - window.makeFirstResponder(window.contentView) - - let windowController = NSWindowController(window: window) - windowController.showWindow(nil) - window.makeKeyAndOrderFront(nil) - - previewWindowController = windowController } } diff --git a/Gaze/Views/Setup/PostureSetupView.swift b/Gaze/Views/Setup/PostureSetupView.swift index 1b14531..47f5e4b 100644 --- a/Gaze/Views/Setup/PostureSetupView.swift +++ b/Gaze/Views/Setup/PostureSetupView.swift @@ -9,55 +9,33 @@ import AppKit import SwiftUI struct PostureSetupView: View { - @ObservedObject var settingsManager: SettingsManager - + @Bindable var settingsManager: SettingsManager @State private var previewWindowController: NSWindowController? var body: some View { VStack(spacing: 0) { - // Fixed header section - VStack(spacing: 16) { - Image(systemName: "figure.stand") - .font(.system(size: 60)) - .foregroundColor(.orange) + SetupHeader(icon: "figure.stand", title: "Posture Reminder", color: .orange) - Text("Posture Reminder") - .font(.system(size: 28, weight: .bold)) - } - .padding(.top, 20) - .padding(.bottom, 30) - - // Vertically centered content Spacer() VStack(spacing: 30) { HStack(spacing: 12) { Button(action: { - // Using properly URL-encoded text fragment - // Points to key findings about sitting posture and behavior relationship with LBP - if let url = URL( - string: - "https://pubmed.ncbi.nlm.nih.gov/40111906/#:~:text=For%20studies%20exploring%20sitting%20posture%2C%20seven%20found%20a%20relationship%20with%20LBP.%20Regarding%20studies%20on%20sitting%20behavior%2C%20only%20one%20showed%20no%20relationship%20between%20LBP%20prevalence" - ) { - #if os(iOS) - UIApplication.shared.open(url) - #elseif os(macOS) - NSWorkspace.shared.open(url) - #endif + if let url = URL(string: "https://pubmed.ncbi.nlm.nih.gov/40111906/#:~:text=For%20studies%20exploring%20sitting%20posture%2C%20seven%20found%20a%20relationship%20with%20LBP.%20Regarding%20studies%20on%20sitting%20behavior%2C%20only%20one%20showed%20no%20relationship%20between%20LBP%20prevalence") { + NSWorkspace.shared.open(url) } }) { Image(systemName: "info.circle") .foregroundColor(.white) - }.buttonStyle(.plain) - Text( - "Regular posture checks help prevent back and neck pain from prolonged sitting" - ) - .font(.headline) - .foregroundColor(.white) + } + .buttonStyle(.plain) + + Text("Regular posture checks help prevent back and neck pain from prolonged sitting") + .font(.headline) + .foregroundColor(.white) } .padding() - .glassEffectIfAvailable( - GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8)) + .glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8)) SliderSection( intervalSettings: Binding( @@ -72,10 +50,7 @@ struct PostureSetupView: View { } ), countdownSettings: nil, - enabled: Binding( - get: { settingsManager.settings.postureTimer.enabled }, - set: { settingsManager.settings.postureTimer.enabled = $0 } - ), + enabled: $settingsManager.settings.postureTimer.enabled, type: "Posture", previewFunc: showPreviewWindow ) @@ -90,35 +65,12 @@ struct PostureSetupView: View { private func showPreviewWindow() { guard let screen = NSScreen.main else { return } - - let window = NSWindow( - contentRect: screen.frame, - styleMask: [.borderless, .fullSizeContentView], - backing: .buffered, - defer: false + previewWindowController = PreviewWindowHelper.showPreview( + on: screen, + content: PostureReminderView(sizePercentage: settingsManager.settings.subtleReminderSize.percentage) { [weak previewWindowController] in + previewWindowController?.window?.close() + } ) - - window.level = .floating - window.isOpaque = false - window.backgroundColor = .clear - window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - window.acceptsMouseMovedEvents = true - - let contentView = PostureReminderView( - sizePercentage: settingsManager.settings.subtleReminderSize.percentage - ) { - [weak window] in - window?.close() - } - - window.contentView = NSHostingView(rootView: contentView) - window.makeFirstResponder(window.contentView) - - let windowController = NSWindowController(window: window) - windowController.showWindow(nil) - window.makeKeyAndOrderFront(nil) - - previewWindowController = windowController } } diff --git a/Gaze/Views/Setup/SmartModeSetupView.swift b/Gaze/Views/Setup/SmartModeSetupView.swift index 455b631..d4bb471 100644 --- a/Gaze/Views/Setup/SmartModeSetupView.swift +++ b/Gaze/Views/Setup/SmartModeSetupView.swift @@ -8,233 +8,24 @@ import SwiftUI struct SmartModeSetupView: View { - @ObservedObject var settingsManager: SettingsManager - @StateObject private var permissionManager = ScreenCapturePermissionManager.shared + @Bindable var settingsManager: SettingsManager + @State private var permissionManager = ScreenCapturePermissionManager.shared var body: some View { VStack(spacing: 0) { - // Fixed header section - VStack(spacing: 16) { - Image(systemName: "brain.fill") - .font(.system(size: 60)) - .foregroundColor(.purple) - - Text("Smart Mode") - .font(.system(size: 28, weight: .bold)) - - Text("Automatically manage timers based on your activity") - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding(.top, 20) - .padding(.bottom, 30) + SetupHeader(icon: "brain.fill", title: "Smart Mode", color: .purple) + + Text("Automatically manage timers based on your activity") + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.bottom, 30) Spacer() VStack(spacing: 24) { - // Auto-pause on fullscreen toggle - VStack(alignment: .leading, spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack { - Image(systemName: "arrow.up.left.and.arrow.down.right") - .foregroundColor(.blue) - Text("Auto-pause on Fullscreen") - .font(.headline) - } - Text( - "Timers will automatically pause when you enter fullscreen mode (videos, games, presentations)" - ) - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Toggle( - "", - isOn: Binding( - get: { settingsManager.settings.smartMode.autoPauseOnFullscreen }, - set: { newValue in - print("🔧 Smart Mode - Auto-pause on fullscreen changed to: \(newValue)") - settingsManager.settings.smartMode.autoPauseOnFullscreen = newValue - - if newValue { - permissionManager.requestAuthorizationIfNeeded() - } - } - ) - ) - .labelsHidden() - } - - if settingsManager.settings.smartMode.autoPauseOnFullscreen, - permissionManager.authorizationStatus != .authorized - { - 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) - .foregroundColor(.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) - } - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8)) - - // Auto-pause on idle toggle with threshold slider - VStack(alignment: .leading, spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack { - Image(systemName: "moon.zzz.fill") - .foregroundColor(.indigo) - Text("Auto-pause on Idle") - .font(.headline) - } - Text( - "Timers will pause when you're inactive for more than the threshold below" - ) - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Toggle( - "", - isOn: Binding( - get: { settingsManager.settings.smartMode.autoPauseOnIdle }, - set: { newValue in - print("🔧 Smart Mode - Auto-pause on idle changed to: \(newValue)") - settingsManager.settings.smartMode.autoPauseOnIdle = newValue - } - ) - ) - .labelsHidden() - } - - if settingsManager.settings.smartMode.autoPauseOnIdle { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Idle Threshold:") - .font(.subheadline) - Spacer() - Text( - "\(settingsManager.settings.smartMode.idleThresholdMinutes) min" - ) - .font(.subheadline) - .foregroundColor(.secondary) - } - - Slider( - value: Binding( - get: { - Double( - settingsManager.settings.smartMode.idleThresholdMinutes) - }, - set: { newValue in - print("🔧 Smart Mode - Idle threshold changed to: \(Int(newValue))") - settingsManager.settings.smartMode.idleThresholdMinutes = - Int(newValue) - } - ), - in: 1...30, - step: 1 - ) - } - .padding(.top, 8) - } - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8)) - - // Usage tracking toggle with reset threshold - VStack(alignment: .leading, spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack { - Image(systemName: "chart.line.uptrend.xyaxis") - .foregroundColor(.green) - Text("Track Usage Statistics") - .font(.headline) - } - Text( - "Monitor active and idle time, with automatic reset after the specified duration" - ) - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Toggle( - "", - isOn: Binding( - get: { settingsManager.settings.smartMode.trackUsage }, - set: { newValue in - print("🔧 Smart Mode - Track usage changed to: \(newValue)") - settingsManager.settings.smartMode.trackUsage = newValue - } - ) - ) - .labelsHidden() - } - - if settingsManager.settings.smartMode.trackUsage { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Reset After:") - .font(.subheadline) - Spacer() - Text( - "\(settingsManager.settings.smartMode.usageResetAfterMinutes) min" - ) - .font(.subheadline) - .foregroundColor(.secondary) - } - - Slider( - value: Binding( - get: { - Double( - settingsManager.settings.smartMode - .usageResetAfterMinutes) - }, - set: { newValue in - print("🔧 Smart Mode - Usage reset after changed to: \(Int(newValue))") - settingsManager.settings.smartMode.usageResetAfterMinutes = - Int(newValue) - } - ), - in: 15...240, - step: 15 - ) - } - .padding(.top, 8) - } - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8)) + fullscreenSection + idleSection + usageTrackingSection } .frame(maxWidth: 600) @@ -244,6 +35,167 @@ struct SmartModeSetupView: View { .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") + .foregroundColor(.blue) + Text("Auto-pause on Fullscreen") + .font(.headline) + } + Text("Timers will automatically pause when you enter fullscreen mode (videos, games, presentations)") + .font(.caption) + .foregroundColor(.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) + .foregroundColor(.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") + .foregroundColor(.indigo) + Text("Auto-pause on Idle") + .font(.headline) + } + Text("Timers will pause when you're inactive for more than the threshold below") + .font(.caption) + .foregroundColor(.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") + .foregroundColor(.green) + Text("Track Usage Statistics") + .font(.headline) + } + Text("Monitor active and idle time, with automatic reset after the specified duration") + .font(.caption) + .foregroundColor(.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) + .foregroundColor(.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 { diff --git a/GazeTests/OnboardingNavigationTests.swift b/GazeTests/OnboardingNavigationTests.swift index c4c07d2..aa37c73 100644 --- a/GazeTests/OnboardingNavigationTests.swift +++ b/GazeTests/OnboardingNavigationTests.swift @@ -27,7 +27,8 @@ final class OnboardingNavigationTests: XCTestCase { // MARK: - Navigation Tests func testOnboardingStartsAtWelcomePage() { - let onboarding = OnboardingContainerView(settingsManager: testEnv.settingsManager as! SettingsManager) + // Use real SettingsManager for view initialization test since @Bindable requires concrete type + let onboarding = OnboardingContainerView(settingsManager: SettingsManager.shared) // Verify initial state XCTAssertFalse(testEnv.settingsManager.settings.hasCompletedOnboarding) diff --git a/GazeTests/Services/SettingsManagerTests.swift b/GazeTests/Services/SettingsManagerTests.swift index 9f05320..3a6eb84 100644 --- a/GazeTests/Services/SettingsManagerTests.swift +++ b/GazeTests/Services/SettingsManagerTests.swift @@ -39,7 +39,7 @@ final class SettingsManagerTests: XCTestCase { let defaults = AppSettings.defaults XCTAssertTrue(defaults.lookAwayTimer.enabled) - XCTAssertTrue(defaults.blinkTimer.enabled) + XCTAssertFalse(defaults.blinkTimer.enabled) // Blink timer is disabled by default XCTAssertTrue(defaults.postureTimer.enabled) XCTAssertFalse(defaults.hasCompletedOnboarding) } @@ -92,7 +92,7 @@ final class SettingsManagerTests: XCTestCase { let expectation = XCTestExpectation(description: "Settings changed") var receivedSettings: AppSettings? - settingsManager.$settings + settingsManager.settingsPublisher .dropFirst() // Skip initial value .sink { settings in receivedSettings = settings @@ -102,6 +102,7 @@ final class SettingsManagerTests: XCTestCase { // Trigger change settingsManager.settings.playSounds = !settingsManager.settings.playSounds + settingsManager.save() await fulfillment(of: [expectation], timeout: 1.0) XCTAssertNotNil(receivedSettings) diff --git a/GazeTests/TestHelpers.swift b/GazeTests/TestHelpers.swift index 8cf45e6..2e23cfd 100644 --- a/GazeTests/TestHelpers.swift +++ b/GazeTests/TestHelpers.swift @@ -13,13 +13,18 @@ import XCTest /// Enhanced mock settings manager with full control over state @MainActor -final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding { - @Published var settings: AppSettings +@Observable +final class EnhancedMockSettingsManager: SettingsProviding { + var settings: AppSettings - var settingsPublisher: Published.Publisher { - $settings + @ObservationIgnored + private let _settingsSubject: CurrentValueSubject + + var settingsPublisher: AnyPublisher { + _settingsSubject.eraseToAnyPublisher() } + @ObservationIgnored private let timerConfigKeyPaths: [TimerType: WritableKeyPath] = [ .lookAway: \.lookAwayTimer, .blink: \.blinkTimer, @@ -27,13 +32,18 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding { ] // Track method calls for verification + @ObservationIgnored private(set) var saveCallCount = 0 + @ObservationIgnored private(set) var saveImmediatelyCallCount = 0 + @ObservationIgnored private(set) var loadCallCount = 0 + @ObservationIgnored private(set) var resetToDefaultsCallCount = 0 init(settings: AppSettings = .defaults) { self.settings = settings + self._settingsSubject = CurrentValueSubject(settings) } func timerConfiguration(for type: TimerType) -> TimerConfiguration { @@ -48,6 +58,7 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding { preconditionFailure("Unknown timer type: \(type)") } settings[keyPath: keyPath] = configuration + _settingsSubject.send(settings) } func allTimerConfigurations() -> [TimerType: TimerConfiguration] { @@ -60,10 +71,12 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding { func save() { saveCallCount += 1 + _settingsSubject.send(settings) } func saveImmediately() { saveImmediatelyCallCount += 1 + _settingsSubject.send(settings) } func load() { @@ -73,6 +86,7 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding { func resetToDefaults() { resetToDefaultsCallCount += 1 settings = .defaults + _settingsSubject.send(settings) } // Test helpers @@ -82,6 +96,7 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding { loadCallCount = 0 resetToDefaultsCallCount = 0 settings = .defaults + _settingsSubject.send(settings) } } diff --git a/GazeTests/Views/BlinkSetupViewTests.swift b/GazeTests/Views/BlinkSetupViewTests.swift index eb7730a..d8d09c6 100644 --- a/GazeTests/Views/BlinkSetupViewTests.swift +++ b/GazeTests/Views/BlinkSetupViewTests.swift @@ -23,9 +23,8 @@ final class BlinkSetupViewTests: XCTestCase { } func testBlinkSetupInitialization() { - let view = BlinkSetupView( - settingsManager: testEnv.settingsManager as! SettingsManager - ) + // Use real SettingsManager for view initialization test since @Bindable requires concrete type + let view = BlinkSetupView(settingsManager: SettingsManager.shared) XCTAssertNotNil(view) } diff --git a/GazeTests/Views/GeneralSetupViewTests.swift b/GazeTests/Views/GeneralSetupViewTests.swift index f63ca17..3022459 100644 --- a/GazeTests/Views/GeneralSetupViewTests.swift +++ b/GazeTests/Views/GeneralSetupViewTests.swift @@ -23,10 +23,8 @@ final class GeneralSetupViewTests: XCTestCase { } func testGeneralSetupInitialization() { - let view = GeneralSetupView( - settingsManager: testEnv.settingsManager as! SettingsManager, - isOnboarding: true - ) + // Use real SettingsManager for view initialization test since @Bindable requires concrete type + let view = GeneralSetupView(settingsManager: SettingsManager.shared, isOnboarding: true) XCTAssertNotNil(view) } diff --git a/GazeTests/Views/LookAwaySetupViewTests.swift b/GazeTests/Views/LookAwaySetupViewTests.swift index 23645a2..f1e4ff2 100644 --- a/GazeTests/Views/LookAwaySetupViewTests.swift +++ b/GazeTests/Views/LookAwaySetupViewTests.swift @@ -23,9 +23,8 @@ final class LookAwaySetupViewTests: XCTestCase { } func testLookAwaySetupInitialization() { - let view = LookAwaySetupView( - settingsManager: testEnv.settingsManager as! SettingsManager - ) + // Use real SettingsManager for view initialization test since @Bindable requires concrete type + let view = LookAwaySetupView(settingsManager: SettingsManager.shared) XCTAssertNotNil(view) } diff --git a/GazeTests/Views/PostureSetupViewTests.swift b/GazeTests/Views/PostureSetupViewTests.swift index 5032cc7..1d48cd0 100644 --- a/GazeTests/Views/PostureSetupViewTests.swift +++ b/GazeTests/Views/PostureSetupViewTests.swift @@ -23,9 +23,8 @@ final class PostureSetupViewTests: XCTestCase { } func testPostureSetupInitialization() { - let view = PostureSetupView( - settingsManager: testEnv.settingsManager as! SettingsManager - ) + // Use real SettingsManager for view initialization test since @Bindable requires concrete type + let view = PostureSetupView(settingsManager: SettingsManager.shared) XCTAssertNotNil(view) }