general: 14.0 new min

This commit is contained in:
Michael Freno
2026-01-15 22:31:48 -05:00
parent 77bc2f9a92
commit 1ace5fd3f7
24 changed files with 689 additions and 929 deletions

View File

@@ -437,7 +437,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.4.1; MARKETING_VERSION = 0.4.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze; PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -473,7 +473,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.4.1; MARKETING_VERSION = 0.4.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze; PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";

View File

@@ -9,6 +9,15 @@
"revision" : "69faaefa7721fba9e434a52c16adf4329c9084db", "revision" : "69faaefa7721fba9e434a52c16adf4329c9084db",
"version" : "4.6.0" "version" : "4.6.0"
} }
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "5581748cef2bae787496fe6d61139aebe0a451f6",
"version" : "2.8.1"
}
} }
], ],
"version" : 3 "version" : 3

View File

@@ -10,9 +10,7 @@ import SwiftUI
@main @main
struct GazeApp: App { struct GazeApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
// Note: SettingsManager.shared is used directly here for SwiftUI view updates @State private var settingsManager = SettingsManager.shared
// AppDelegate uses ServiceContainer for dependency injection
@StateObject private var settingsManager = SettingsManager.shared
init() { init() {
// Handle test launch arguments // Handle test launch arguments

View File

@@ -2,47 +2,26 @@
// SettingsProviding.swift // SettingsProviding.swift
// Gaze // Gaze
// //
// Protocol abstraction for SettingsManager to enable dependency injection and testing.
//
import Combine import Combine
import Foundation import Foundation
/// Protocol that defines the interface for managing application settings.
/// This abstraction allows for dependency injection and easy mocking in tests.
@MainActor @MainActor
protocol SettingsProviding: AnyObject, ObservableObject { protocol SettingsProviding: AnyObject, Observable {
/// The current application settings
var settings: AppSettings { get set } var settings: AppSettings { get set }
var settingsPublisher: AnyPublisher<AppSettings, Never> { get }
/// Publisher for observing settings changes
var settingsPublisher: Published<AppSettings>.Publisher { get }
/// Retrieves the timer configuration for a specific timer type
func timerConfiguration(for type: TimerType) -> TimerConfiguration func timerConfiguration(for type: TimerType) -> TimerConfiguration
/// Updates the timer configuration for a specific timer type
func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration)
/// Returns all timer configurations
func allTimerConfigurations() -> [TimerType: TimerConfiguration] func allTimerConfigurations() -> [TimerType: TimerConfiguration]
/// Saves settings to persistent storage
func save() func save()
/// Forces immediate save
func saveImmediately() func saveImmediately()
/// Loads settings from persistent storage
func load() func load()
/// Resets settings to default values
func resetToDefaults() func resetToDefaults()
} }
/// Extension to provide the publisher for SettingsManager
extension SettingsManager: SettingsProviding { extension SettingsManager: SettingsProviding {
var settingsPublisher: Published<AppSettings>.Publisher { var settingsPublisher: AnyPublisher<AppSettings, Never> {
$settings _settingsSubject.eraseToAnyPublisher()
} }
} }

View File

@@ -120,13 +120,18 @@ final class ServiceContainer {
/// A mock settings manager for use in ServiceContainer.forTesting() /// A mock settings manager for use in ServiceContainer.forTesting()
/// This is a minimal implementation - use the full MockSettingsManager from tests for more features /// This is a minimal implementation - use the full MockSettingsManager from tests for more features
@MainActor @MainActor
final class MockSettingsManager: ObservableObject, SettingsProviding { @Observable
@Published var settings: AppSettings final class MockSettingsManager: SettingsProviding {
var settings: AppSettings
var settingsPublisher: Published<AppSettings>.Publisher { @ObservationIgnored
$settings private let _settingsSubject: CurrentValueSubject<AppSettings, Never>
var settingsPublisher: AnyPublisher<AppSettings, Never> {
_settingsSubject.eraseToAnyPublisher()
} }
@ObservationIgnored
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [ private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [
.lookAway: \.lookAwayTimer, .lookAway: \.lookAwayTimer,
.blink: \.blinkTimer, .blink: \.blinkTimer,
@@ -135,6 +140,7 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
init(settings: AppSettings = .defaults) { init(settings: AppSettings = .defaults) {
self.settings = settings self.settings = settings
self._settingsSubject = CurrentValueSubject(settings)
} }
func timerConfiguration(for type: TimerType) -> TimerConfiguration { func timerConfiguration(for type: TimerType) -> TimerConfiguration {
@@ -149,6 +155,7 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
preconditionFailure("Unknown timer type: \(type)") preconditionFailure("Unknown timer type: \(type)")
} }
settings[keyPath: keyPath] = configuration settings[keyPath: keyPath] = configuration
_settingsSubject.send(settings)
} }
func allTimerConfigurations() -> [TimerType: TimerConfiguration] { func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
@@ -159,8 +166,11 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
return configs return configs
} }
func save() {} func save() { _settingsSubject.send(settings) }
func saveImmediately() {} func saveImmediately() { _settingsSubject.send(settings) }
func load() {} func load() {}
func resetToDefaults() { settings = .defaults } func resetToDefaults() {
settings = .defaults
_settingsSubject.send(settings)
}
} }

View File

@@ -7,17 +7,31 @@
import Combine import Combine
import Foundation import Foundation
import Observation
@MainActor @MainActor
class SettingsManager: ObservableObject { @Observable
final class SettingsManager {
static let shared = SettingsManager() static let shared = SettingsManager()
@Published var settings: AppSettings
var settings: AppSettings {
didSet { _settingsSubject.send(settings) }
}
@ObservationIgnored
let _settingsSubject = CurrentValueSubject<AppSettings, Never>(.defaults)
@ObservationIgnored
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
@ObservationIgnored
private let settingsKey = "gazeAppSettings" private let settingsKey = "gazeAppSettings"
@ObservationIgnored
private var saveCancellable: AnyCancellable? private var saveCancellable: AnyCancellable?
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = @ObservationIgnored
[ private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [
.lookAway: \.lookAwayTimer, .lookAway: \.lookAwayTimer,
.blink: \.blinkTimer, .blink: \.blinkTimer,
.posture: \.postureTimer, .posture: \.postureTimer,
@@ -25,17 +39,12 @@ class SettingsManager: ObservableObject {
private init() { private init() {
self.settings = Self.loadSettings() self.settings = Self.loadSettings()
_settingsSubject.send(settings)
setupDebouncedSave() setupDebouncedSave()
} }
deinit {
saveCancellable?.cancel()
// Final save is called by AppDelegate.applicationWillTerminate
}
private func setupDebouncedSave() { private func setupDebouncedSave() {
saveCancellable = saveCancellable = _settingsSubject
$settings
.debounce(for: .milliseconds(500), scheduler: RunLoop.main) .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.sink { [weak self] _ in .sink { [weak self] _ in
self?.save() self?.save()
@@ -46,30 +55,22 @@ class SettingsManager: ObservableObject {
guard let data = UserDefaults.standard.data(forKey: "gazeAppSettings") else { guard let data = UserDefaults.standard.data(forKey: "gazeAppSettings") else {
return .defaults return .defaults
} }
do { do {
let settings = try JSONDecoder().decode(AppSettings.self, from: data) return try JSONDecoder().decode(AppSettings.self, from: data)
return settings
} catch { } catch {
return .defaults 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() { func save() {
do { do {
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(settings) let data = try encoder.encode(settings)
userDefaults.set(data, forKey: settingsKey) 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() { func saveImmediately() {
save() save()
} }
@@ -96,7 +97,6 @@ class SettingsManager: ObservableObject {
settings[keyPath: keyPath] = configuration settings[keyPath: keyPath] = configuration
} }
/// Returns all timer configurations as a dictionary
func allTimerConfigurations() -> [TimerType: TimerConfiguration] { func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
var configs: [TimerType: TimerConfiguration] = [:] var configs: [TimerType: TimerConfiguration] = [:]
for (type, keyPath) in timerConfigKeyPaths { for (type, keyPath) in timerConfigKeyPaths {
@@ -104,15 +104,4 @@ class SettingsManager: ObservableObject {
} }
return configs 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)")
}
}
} }

View File

@@ -0,0 +1,35 @@
//
// PreviewWindowHelper.swift
// Gaze
//
// Created by Mike Freno on 1/15/26.
//
import AppKit
import SwiftUI
enum PreviewWindowHelper {
static func showPreview<Content: View>(
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
}
}

View File

@@ -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)
}
}

View File

@@ -1,3 +1,10 @@
//
// OnboardingContainerView.swift
// Gaze
//
// Created by Mike Freno on 1/7/26.
//
import AppKit import AppKit
import SwiftUI import SwiftUI
@@ -27,9 +34,7 @@ final class OnboardingWindowPresenter {
private var closeObserver: NSObjectProtocol? private var closeObserver: NSObjectProtocol?
func show(settingsManager: SettingsManager) { func show(settingsManager: SettingsManager) {
if activateIfPresent() { if activateIfPresent() { return }
return
}
createWindow(settingsManager: settingsManager) createWindow(settingsManager: settingsManager)
} }
@@ -39,26 +44,16 @@ final class OnboardingWindowPresenter {
windowController = nil windowController = nil
return false return false
} }
// Ensure the window is brought to front and focused properly for menu bar apps
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
// Additional focus handling for menu bar applications
if let window = windowController?.window {
window.makeMain() window.makeMain()
}
return true return true
} }
func close() { func close() {
windowController?.close() windowController?.close()
windowController = nil windowController = nil
if let closeObserver { removeCloseObserver()
NotificationCenter.default.removeObserver(closeObserver)
self.closeObserver = nil
}
} }
private func createWindow(settingsManager: SettingsManager) { private func createWindow(settingsManager: SettingsManager) {
@@ -85,34 +80,29 @@ final class OnboardingWindowPresenter {
windowController = controller windowController = controller
closeObserver.map(NotificationCenter.default.removeObserver) removeCloseObserver()
closeObserver = NotificationCenter.default.addObserver( closeObserver = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification, forName: NSWindow.willCloseNotification,
object: window, object: window,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
self?.windowController = nil self?.windowController = nil
if let closeObserver = self?.closeObserver { self?.removeCloseObserver()
NotificationCenter.default.removeObserver(closeObserver)
}
self?.closeObserver = nil
// Notify AppDelegate that onboarding window closed
NotificationCenter.default.post(name: Notification.Name("OnboardingWindowDidClose"), object: nil) NotificationCenter.default.post(name: Notification.Name("OnboardingWindowDidClose"), object: nil)
} }
} }
deinit { private func removeCloseObserver() {
if let closeObserver { if let observer = closeObserver {
NotificationCenter.default.removeObserver(closeObserver) NotificationCenter.default.removeObserver(observer)
closeObserver = nil
} }
} }
} }
struct OnboardingContainerView: View { struct OnboardingContainerView: View {
@ObservedObject var settingsManager: SettingsManager @Bindable var settingsManager: SettingsManager
@State private var currentPage = 0 @State private var currentPage = 0
@Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
ZStack { ZStack {
@@ -122,46 +112,42 @@ struct OnboardingContainerView: View {
TabView(selection: $currentPage) { TabView(selection: $currentPage) {
WelcomeView() WelcomeView()
.tag(0) .tag(0)
.tabItem { .tabItem { Image(systemName: "hand.wave.fill") }
Image(systemName: "hand.wave.fill")
}
LookAwaySetupView(settingsManager: settingsManager) LookAwaySetupView(settingsManager: settingsManager)
.tag(1) .tag(1)
.tabItem { .tabItem { Image(systemName: "eye.fill") }
Image(systemName: "eye.fill")
}
BlinkSetupView(settingsManager: settingsManager) BlinkSetupView(settingsManager: settingsManager)
.tag(2) .tag(2)
.tabItem { .tabItem { Image(systemName: "eye.circle.fill") }
Image(systemName: "eye.circle.fill")
}
PostureSetupView(settingsManager: settingsManager) PostureSetupView(settingsManager: settingsManager)
.tag(3) .tag(3)
.tabItem { .tabItem { Image(systemName: "figure.stand") }
Image(systemName: "figure.stand")
}
GeneralSetupView( GeneralSetupView(settingsManager: settingsManager, isOnboarding: true)
settingsManager: settingsManager,
isOnboarding: true
)
.tag(4) .tag(4)
.tabItem { .tabItem { Image(systemName: "gearshape.fill") }
Image(systemName: "gearshape.fill")
}
CompletionView() CompletionView()
.tag(5) .tag(5)
.tabItem { .tabItem { Image(systemName: "checkmark.circle.fill") }
Image(systemName: "checkmark.circle.fill")
}
} }
.tabViewStyle(.automatic) .tabViewStyle(.automatic)
if currentPage >= 0 { navigationButtons
}
}
#if APPSTORE
.frame(minWidth: 1000, minHeight: 700)
#else
.frame(minWidth: 1000, minHeight: 900)
#endif
}
@ViewBuilder
private var navigationButtons: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
if currentPage > 0 { if currentPage > 0 {
Button(action: { currentPage -= 1 }) { Button(action: { currentPage -= 1 }) {
@@ -170,16 +156,12 @@ struct OnboardingContainerView: View {
Text("Back") Text("Back")
} }
.font(.headline) .font(.headline)
.frame( .frame(minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44)
minWidth: 100, maxWidth: .infinity, minHeight: 44,
maxHeight: 44, alignment: .center
)
.foregroundColor(.primary) .foregroundColor(.primary)
.contentShape(RoundedRectangle(cornerRadius: 10)) .contentShape(RoundedRectangle(cornerRadius: 10))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.glassEffectIfAvailable( .glassEffectIfAvailable(GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10))
GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10))
} }
Button(action: { Button(action: {
@@ -189,60 +171,28 @@ struct OnboardingContainerView: View {
currentPage += 1 currentPage += 1
} }
}) { }) {
Text( Text(currentPage == 0 ? "Let's Get Started" : currentPage == 5 ? "Get Started" : "Continue")
currentPage == 0
? "Let's Get Started"
: currentPage == 5 ? "Get Started" : "Continue"
)
.font(.headline) .font(.headline)
.frame( .frame(minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44)
minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44,
alignment: .center
)
.foregroundColor(.white) .foregroundColor(.white)
.contentShape(RoundedRectangle(cornerRadius: 10)) .contentShape(RoundedRectangle(cornerRadius: 10))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.glassEffectIfAvailable( .glassEffectIfAvailable(
GlassStyle.regular.tint(currentPage == 5 ? .green : .accentColor) GlassStyle.regular.tint(currentPage == 5 ? .green : .accentColor).interactive(),
.interactive(), in: .rect(cornerRadius: 10)
in: .rect(cornerRadius: 10)) )
} }
.padding(.horizontal, 40) .padding(.horizontal, 40)
.padding(.bottom, 20) .padding(.bottom, 20)
} }
}
}
#if APPSTORE
.frame(
minWidth: 1000,
minHeight: 700
)
#else
.frame(
minWidth: 1000,
minHeight: 900
)
#endif
}
private func completeOnboarding() { private func completeOnboarding() {
// Mark onboarding as complete - settings are already being updated in real-time
settingsManager.settings.hasCompletedOnboarding = true settingsManager.settings.hasCompletedOnboarding = true
OnboardingWindowPresenter.shared.close()
}
}
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)
}
}
}
}
#Preview("Onboarding Container") { #Preview("Onboarding Container") {
OnboardingContainerView(settingsManager: SettingsManager.shared) OnboardingContainerView(settingsManager: SettingsManager.shared)
} }

View File

@@ -15,9 +15,7 @@ final class SettingsWindowPresenter {
private var closeObserver: NSObjectProtocol? private var closeObserver: NSObjectProtocol?
func show(settingsManager: SettingsManager, initialTab: Int = 0) { func show(settingsManager: SettingsManager, initialTab: Int = 0) {
if focusExistingWindow(tab: initialTab) { if focusExistingWindow(tab: initialTab) { return }
return
}
createWindow(settingsManager: settingsManager, initialTab: initialTab) createWindow(settingsManager: settingsManager, initialTab: initialTab)
} }
@@ -67,15 +65,12 @@ final class SettingsWindowPresenter {
window.setFrameAutosaveName("SettingsWindow") window.setFrameAutosaveName("SettingsWindow")
window.isReleasedWhenClosed = false window.isReleasedWhenClosed = false
let contentView = SettingsWindowView( window.contentView = NSHostingView(
settingsManager: settingsManager, rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab)
initialTab: initialTab
) )
window.contentView = NSHostingView(rootView: contentView)
let controller = NSWindowController(window: window) let controller = NSWindowController(window: window)
controller.showWindow(nil) controller.showWindow(nil)
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
@@ -90,32 +85,21 @@ final class SettingsWindowPresenter {
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
self?.windowController = nil self?.windowController = nil
self?.removeCloseObserver() self?.removeCloseObserver()
// Notify AppDelegate that settings window closed
NotificationCenter.default.post(name: Notification.Name("SettingsWindowDidClose"), object: nil) NotificationCenter.default.post(name: Notification.Name("SettingsWindowDidClose"), object: nil)
} }
} }
} }
@MainActor
private func removeCloseObserver() { 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 { if let observer = closeObserver {
NotificationCenter.default.removeObserver(observer) NotificationCenter.default.removeObserver(observer)
closeObserver = nil
} }
} }
} }
struct SettingsWindowView: View { struct SettingsWindowView: View {
@ObservedObject var settingsManager: SettingsManager @Bindable var settingsManager: SettingsManager
@State private var selectedSection: SettingsSection @State private var selectedSection: SettingsSection
init(settingsManager: SettingsManager, initialTab: Int = 0) { init(settingsManager: SettingsManager, initialTab: Int = 0) {
@@ -140,26 +124,21 @@ struct SettingsWindowView: View {
ScrollView { ScrollView {
detailView(for: selectedSection) detailView(for: selectedSection)
} }
}.onReceive( }
NotificationCenter.default.publisher( .onReceive(NotificationCenter.default.publisher(for: Notification.Name("SwitchToSettingsTab"))) { notification in
for: Notification.Name("SwitchToSettingsTab"))
) { notification in
if let tab = notification.object as? Int, if let tab = notification.object as? Int,
let section = SettingsSection(rawValue: tab) let section = SettingsSection(rawValue: tab) {
{
selectedSection = section selectedSection = section
} }
} }
#if DEBUG #if DEBUG
Divider() Divider()
HStack { HStack {
Button("Retrigger Onboarding") { Button("Retrigger Onboarding") {
retriggerOnboarding() retriggerOnboarding()
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
Spacer() Spacer()
} }
.padding() .padding()
@@ -167,15 +146,9 @@ struct SettingsWindowView: View {
} }
} }
#if APPSTORE #if APPSTORE
.frame( .frame(minWidth: 1000, minHeight: 700)
minWidth: 1000,
minHeight: 700
)
#else #else
.frame( .frame(minWidth: 1000, minHeight: 900)
minWidth: 1000,
minHeight: 900
)
#endif #endif
} }
@@ -183,10 +156,7 @@ struct SettingsWindowView: View {
private func detailView(for section: SettingsSection) -> some View { private func detailView(for section: SettingsSection) -> some View {
switch section { switch section {
case .general: case .general:
GeneralSetupView( GeneralSetupView(settingsManager: settingsManager, isOnboarding: false)
settingsManager: settingsManager,
isOnboarding: false
)
case .lookAway: case .lookAway:
LookAwaySetupView(settingsManager: settingsManager) LookAwaySetupView(settingsManager: settingsManager)
case .blink: case .blink:
@@ -210,7 +180,6 @@ struct SettingsWindowView: View {
#if DEBUG #if DEBUG
private func retriggerOnboarding() { private func retriggerOnboarding() {
SettingsWindowPresenter.shared.close() SettingsWindowPresenter.shared.close()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
settingsManager.settings.hasCompletedOnboarding = false settingsManager.settings.hasCompletedOnboarding = false
} }

View File

@@ -7,10 +7,9 @@
import SwiftUI import SwiftUI
// Wrapper to properly observe AppDelegate changes in MenuBarExtra
struct MenuBarContentWrapper: View { struct MenuBarContentWrapper: View {
@ObservedObject var appDelegate: AppDelegate @ObservedObject var appDelegate: AppDelegate
@ObservedObject var settingsManager: SettingsManager @Bindable var settingsManager: SettingsManager
var onQuit: () -> Void var onQuit: () -> Void
var onOpenSettings: () -> Void var onOpenSettings: () -> Void
var onOpenSettingsTab: (Int) -> Void var onOpenSettingsTab: (Int) -> Void
@@ -71,7 +70,7 @@ struct MenuBarHoverButtonStyle: ButtonStyle {
struct MenuBarContentView: View { struct MenuBarContentView: View {
var timerEngine: TimerEngine? var timerEngine: TimerEngine?
@ObservedObject var settingsManager: SettingsManager @Bindable var settingsManager: SettingsManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var onQuit: () -> Void var onQuit: () -> Void
var onOpenSettings: () -> Void var onOpenSettings: () -> Void
@@ -251,7 +250,7 @@ struct MenuBarContentView: View {
struct TimerStatusRowWithIndividualControls: View { struct TimerStatusRowWithIndividualControls: View {
let identifier: TimerIdentifier let identifier: TimerIdentifier
@ObservedObject var timerEngine: TimerEngine @ObservedObject var timerEngine: TimerEngine
@ObservedObject var settingsManager: SettingsManager @Bindable var settingsManager: SettingsManager
var onSkip: () -> Void var onSkip: () -> Void
var onDevTrigger: (() -> Void)? = nil var onDevTrigger: (() -> Void)? = nil
var onTogglePause: (Bool) -> Void var onTogglePause: (Bool) -> Void

View File

@@ -9,58 +9,36 @@ import AppKit
import SwiftUI import SwiftUI
struct BlinkSetupView: View { struct BlinkSetupView: View {
@ObservedObject var settingsManager: SettingsManager @Bindable var settingsManager: SettingsManager
@State private var previewWindowController: NSWindowController? @State private var previewWindowController: NSWindowController?
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Fixed header section SetupHeader(icon: "eye.circle", title: "Blink Reminder", color: .green)
VStack(spacing: 16) {
Image(systemName: "eye.circle")
.font(.system(size: 60))
.foregroundColor(.green)
Text("Blink Reminder")
.font(.system(size: 28, weight: .bold))
}
.padding(.top, 20)
.padding(.bottom, 30)
// Vertically centered content
Spacer() Spacer()
VStack(spacing: 30) { VStack(spacing: 30) {
HStack(spacing: 12) { HStack(spacing: 12) {
Button(action: { Button(action: {
if let url = URL( 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.") {
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) NSWorkspace.shared.open(url)
#endif
} }
}) { }) {
Image(systemName: "info.circle") Image(systemName: "info.circle")
.foregroundColor(.white) .foregroundColor(.white)
}.buttonStyle(.plain) }
Text( .buttonStyle(.plain)
"We blink much less when focusing on screens. Regular blink reminders help prevent dry eyes."
) Text("We blink much less when focusing on screens. Regular blink reminders help prevent dry eyes.")
.font(.headline) .font(.headline)
.foregroundColor(.white) .foregroundColor(.white)
} }
.padding() .padding()
.glassEffectIfAvailable( .glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8))
GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8))
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
Toggle("Enable Blink Reminders", isOn: Binding( Toggle("Enable Blink Reminders", isOn: $settingsManager.settings.blinkTimer.enabled)
get: { settingsManager.settings.blinkTimer.enabled },
set: { settingsManager.settings.blinkTimer.enabled = $0 }
))
.font(.headline) .font(.headline)
if settingsManager.settings.blinkTimer.enabled { if settingsManager.settings.blinkTimer.enabled {
@@ -74,7 +52,10 @@ struct BlinkSetupView: View {
value: Binding( value: Binding(
get: { Double(settingsManager.settings.blinkTimer.intervalSeconds / 60) }, get: { Double(settingsManager.settings.blinkTimer.intervalSeconds / 60) },
set: { settingsManager.settings.blinkTimer.intervalSeconds = Int($0) * 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") Text("\(settingsManager.settings.blinkTimer.intervalSeconds / 60) min")
.frame(width: 60, alignment: .trailing) .frame(width: 60, alignment: .trailing)
@@ -87,23 +68,16 @@ struct BlinkSetupView: View {
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
if settingsManager.settings.blinkTimer.enabled { if settingsManager.settings.blinkTimer.enabled {
Text( Text("You will be subtly reminded every \(settingsManager.settings.blinkTimer.intervalSeconds / 60) minutes to blink")
"You will be subtly reminded every \(settingsManager.settings.blinkTimer.intervalSeconds / 60) minutes to blink"
)
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} else { } else {
Text( Text("Blink reminders are currently disabled.")
"Blink reminders are currently disabled."
)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
// Preview button Button(action: showPreviewWindow) {
Button(action: {
showPreviewWindow()
}) {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "eye") Image(systemName: "eye")
.foregroundColor(.white) .foregroundColor(.white)
@@ -116,9 +90,7 @@ struct BlinkSetupView: View {
.contentShape(RoundedRectangle(cornerRadius: 10)) .contentShape(RoundedRectangle(cornerRadius: 10))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.glassEffectIfAvailable( .glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10))
GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10)
)
} }
Spacer() Spacer()
@@ -130,35 +102,12 @@ struct BlinkSetupView: View {
private func showPreviewWindow() { private func showPreviewWindow() {
guard let screen = NSScreen.main else { return } guard let screen = NSScreen.main else { return }
previewWindowController = PreviewWindowHelper.showPreview(
let window = NSWindow( on: screen,
contentRect: screen.frame, content: BlinkReminderView(sizePercentage: settingsManager.settings.subtleReminderSize.percentage) { [weak previewWindowController] in
styleMask: [.borderless, .fullSizeContentView], previewWindowController?.window?.close()
backing: .buffered,
defer: false
)
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
} }
} }

View File

@@ -10,7 +10,7 @@ import SwiftUI
import Foundation import Foundation
struct EnforceModeSetupView: View { struct EnforceModeSetupView: View {
@ObservedObject var settingsManager: SettingsManager @Bindable var settingsManager: SettingsManager
@ObservedObject var cameraService = CameraAccessService.shared @ObservedObject var cameraService = CameraAccessService.shared
@ObservedObject var eyeTrackingService = EyeTrackingService.shared @ObservedObject var eyeTrackingService = EyeTrackingService.shared
@ObservedObject var enforceModeService = EnforceModeService.shared @ObservedObject var enforceModeService = EnforceModeService.shared
@@ -26,15 +26,7 @@ struct EnforceModeSetupView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
VStack(spacing: 16) { SetupHeader(icon: "video.fill", title: "Enforce Mode", color: .accentColor)
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)
Spacer() Spacer()

View File

@@ -8,22 +8,13 @@
import SwiftUI import SwiftUI
struct GeneralSetupView: View { struct GeneralSetupView: View {
@ObservedObject var settingsManager: SettingsManager @Bindable var settingsManager: SettingsManager
@ObservedObject var updateManager = UpdateManager.shared var updateManager = UpdateManager.shared
var isOnboarding: Bool = true var isOnboarding: Bool = true
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Fixed header section SetupHeader(icon: "gearshape.fill", title: isOnboarding ? "Final Settings" : "General Settings", color: .accentColor)
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)
Spacer() Spacer()
VStack(spacing: 30) { VStack(spacing: 30) {
@@ -33,6 +24,27 @@ struct GeneralSetupView: View {
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
VStack(spacing: 20) { VStack(spacing: 20) {
launchAtLoginToggle
#if !APPSTORE
softwareUpdatesSection
#endif
subtleReminderSizeSection
#if !APPSTORE
supportSection
#endif
}
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
}
private var launchAtLoginToggle: some View {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Launch at Login") Text("Launch at Login")
@@ -42,22 +54,18 @@ struct GeneralSetupView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
Spacer() Spacer()
Toggle( Toggle("", isOn: $settingsManager.settings.launchAtLogin)
"",
isOn: Binding(
get: { settingsManager.settings.launchAtLogin },
set: { settingsManager.settings.launchAtLogin = $0 }
)
)
.labelsHidden() .labelsHidden()
.onChange(of: settingsManager.settings.launchAtLogin) { isEnabled in .onChange(of: settingsManager.settings.launchAtLogin) { _, isEnabled in
applyLaunchAtLoginSetting(enabled: isEnabled) applyLaunchAtLoginSetting(enabled: isEnabled)
} }
} }
.padding() .padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
#if !APPSTORE #if !APPSTORE
private var softwareUpdatesSection: some View {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Software Updates") Text("Software Updates")
@@ -83,17 +91,19 @@ struct GeneralSetupView: View {
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
Toggle( Toggle("Automatically check for updates", isOn: Binding(
"Automatically check for updates", get: { updateManager.automaticallyChecksForUpdates },
isOn: $updateManager.automaticallyChecksForUpdates set: { updateManager.automaticallyChecksForUpdates = $0 }
) ))
.labelsHidden() .labelsHidden()
.help("Check for new versions of Gaze in the background") .help("Check for new versions of Gaze in the background")
} }
.padding() .padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
#endif #endif
private var subtleReminderSizeSection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Subtle Reminder Size") Text("Subtle Reminder Size")
.font(.headline) .font(.headline)
@@ -104,29 +114,16 @@ struct GeneralSetupView: View {
HStack(spacing: 12) { HStack(spacing: 12) {
ForEach(ReminderSize.allCases, id: \.self) { size in ForEach(ReminderSize.allCases, id: \.self) { size in
Button(action: { Button(action: { settingsManager.settings.subtleReminderSize = size }) {
settingsManager.settings.subtleReminderSize = size
}) {
VStack(spacing: 8) { VStack(spacing: 8) {
Circle() Circle()
.fill( .fill(settingsManager.settings.subtleReminderSize == size ? Color.accentColor : Color.secondary.opacity(0.3))
settingsManager.settings.subtleReminderSize == size .frame(width: iconSize(for: size), height: iconSize(for: size))
? Color.accentColor
: Color.secondary.opacity(0.3)
)
.frame(
width: iconSize(for: size),
height: iconSize(for: size))
Text(size.displayName) Text(size.displayName)
.font(.caption) .font(.caption)
.fontWeight( .fontWeight(settingsManager.settings.subtleReminderSize == size ? .semibold : .regular)
settingsManager.settings.subtleReminderSize == size .foregroundColor(settingsManager.settings.subtleReminderSize == size ? .primary : .secondary)
? .semibold : .regular
)
.foregroundColor(
settingsManager.settings.subtleReminderSize == size
? .primary : .secondary)
} }
.frame(maxWidth: .infinity, minHeight: 60) .frame(maxWidth: .infinity, minHeight: 60)
.padding(.vertical, 12) .padding(.vertical, 12)
@@ -142,83 +139,35 @@ struct GeneralSetupView: View {
} }
.padding() .padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
#if !APPSTORE #if !APPSTORE
private var supportSection: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
Text("Support & Contribute") Text("Support & Contribute")
.font(.headline) .font(.headline)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
// GitHub Link ExternalLinkButton(
Button(action: { icon: "chevron.left.forwardslash.chevron.right",
if let url = URL(string: "https://github.com/mikefreno/Gaze") { title: "View on GitHub",
NSWorkspace.shared.open(url) subtitle: "Star the repo, report issues, contribute",
} url: "https://github.com/mikefreno/Gaze",
}) { tint: nil
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: { ExternalLinkButton(
if let url = URL(string: "https://buymeacoffee.com/mikefreno") { icon: "cup.and.saucer.fill",
NSWorkspace.shared.open(url) iconColor: .brown,
} title: "Buy Me a Coffee",
}) { subtitle: "Support development of Gaze",
HStack { url: "https://buymeacoffee.com/mikefreno",
Image(systemName: "cup.and.saucer.fill") tint: .orange
.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() .padding()
.frame(maxWidth: .infinity)
.cornerRadius(10)
.contentShape(RoundedRectangle(cornerRadius: 10))
} }
.buttonStyle(.plain)
.glassEffectIfAvailable(
GlassStyle.regular.tint(.orange).interactive(),
in: .rect(cornerRadius: 10))
}
.padding()
#endif #endif
}
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
}
private func applyLaunchAtLoginSetting(enabled: Bool) { private func applyLaunchAtLoginSetting(enabled: Bool) {
do { do {
@@ -227,9 +176,7 @@ struct GeneralSetupView: View {
} else { } else {
try LaunchAtLoginManager.disable() try LaunchAtLoginManager.disable()
} }
} catch { } catch {}
//TODO: see what can be done here
}
} }
private func iconSize(for size: ReminderSize) -> CGFloat { private func iconSize(for size: ReminderSize) -> CGFloat {
@@ -241,9 +188,48 @@ struct GeneralSetupView: View {
} }
} }
#Preview("Settings Onboarding") { struct ExternalLinkButton: View {
GeneralSetupView( let icon: String
settingsManager: SettingsManager.shared, var iconColor: Color = .primary
isOnboarding: true 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)
}

View File

@@ -8,38 +8,22 @@
import AppKit import AppKit
import SwiftUI import SwiftUI
#if os(iOS)
import UIKit
#endif
struct LookAwaySetupView: View { struct LookAwaySetupView: View {
@ObservedObject var settingsManager: SettingsManager @Bindable var settingsManager: SettingsManager
@State private var previewWindowController: NSWindowController? @State private var previewWindowController: NSWindowController?
@ObservedObject var cameraAccess = CameraAccessService.shared var cameraAccess = CameraAccessService.shared
@State private var failedCameraAccess = false @State private var failedCameraAccess = false
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Fixed header section SetupHeader(icon: "eye.fill", title: "Look Away Reminder", color: .accentColor)
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)
Spacer() Spacer()
VStack(spacing: 30) { VStack(spacing: 30) {
InfoBox( InfoBox(
text: "Suggested: 20-20-20 rule", text: "Suggested: 20-20-20 rule",
url: 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."
"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( SliderSection(
@@ -51,8 +35,7 @@ struct LookAwaySetupView: View {
) )
}, },
set: { newValue in set: { newValue in
settingsManager.settings.lookAwayTimer.intervalSeconds = settingsManager.settings.lookAwayTimer.intervalSeconds = (newValue.val ?? 20) * 60
(newValue.val ?? 20) * 60
} }
), ),
countdownSettings: Binding( countdownSettings: Binding(
@@ -66,23 +49,13 @@ struct LookAwaySetupView: View {
settingsManager.settings.lookAwayCountdownSeconds = newValue.val ?? 20 settingsManager.settings.lookAwayCountdownSeconds = newValue.val ?? 20
} }
), ),
enabled: Binding( enabled: $settingsManager.settings.lookAwayTimer.enabled,
get: { settingsManager.settings.lookAwayTimer.enabled },
set: { settingsManager.settings.lookAwayTimer.enabled = $0 }
),
type: "Look away", type: "Look away",
previewFunc: showPreviewWindow previewFunc: showPreviewWindow
) )
Toggle(
"Enable enforcement mode", Toggle("Enable enforcement mode", isOn: $settingsManager.settings.enforcementMode)
isOn: Binding( .onChange(of: settingsManager.settings.enforcementMode) { _, newMode in
get: { settingsManager.settings.enforcementMode },
set: { settingsManager.settings.enforcementMode = $0 }
)
)
.onChange(
of: settingsManager.settings.enforcementMode,
) { newMode in
if newMode && !cameraAccess.isCameraAuthorized { if newMode && !cameraAccess.isCameraAuthorized {
Task { Task {
do { do {
@@ -93,14 +66,9 @@ struct LookAwaySetupView: View {
} }
} }
} }
}
}
}
}
#if failedCameraAccess
Text(
"Camera access denied. Please enable camera access in System Settings if you want to use enforcement mode."
)
#endif
Spacer() Spacer()
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -110,35 +78,12 @@ struct LookAwaySetupView: View {
private func showPreviewWindow() { private func showPreviewWindow() {
guard let screen = NSScreen.main else { return } guard let screen = NSScreen.main else { return }
previewWindowController = PreviewWindowHelper.showPreview(
let window = NSWindow( on: screen,
contentRect: screen.frame, content: LookAwayReminderView(countdownSeconds: settingsManager.settings.lookAwayCountdownSeconds) { [weak previewWindowController] in
styleMask: [.borderless, .fullSizeContentView], previewWindowController?.window?.close()
backing: .buffered,
defer: false
)
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
} }
} }

View File

@@ -9,55 +9,33 @@ import AppKit
import SwiftUI import SwiftUI
struct PostureSetupView: View { struct PostureSetupView: View {
@ObservedObject var settingsManager: SettingsManager @Bindable var settingsManager: SettingsManager
@State private var previewWindowController: NSWindowController? @State private var previewWindowController: NSWindowController?
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Fixed header section SetupHeader(icon: "figure.stand", title: "Posture Reminder", color: .orange)
VStack(spacing: 16) {
Image(systemName: "figure.stand")
.font(.system(size: 60))
.foregroundColor(.orange)
Text("Posture Reminder")
.font(.system(size: 28, weight: .bold))
}
.padding(.top, 20)
.padding(.bottom, 30)
// Vertically centered content
Spacer() Spacer()
VStack(spacing: 30) { VStack(spacing: 30) {
HStack(spacing: 12) { HStack(spacing: 12) {
Button(action: { Button(action: {
// Using properly URL-encoded text fragment 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") {
// 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) NSWorkspace.shared.open(url)
#endif
} }
}) { }) {
Image(systemName: "info.circle") Image(systemName: "info.circle")
.foregroundColor(.white) .foregroundColor(.white)
}.buttonStyle(.plain) }
Text( .buttonStyle(.plain)
"Regular posture checks help prevent back and neck pain from prolonged sitting"
) Text("Regular posture checks help prevent back and neck pain from prolonged sitting")
.font(.headline) .font(.headline)
.foregroundColor(.white) .foregroundColor(.white)
} }
.padding() .padding()
.glassEffectIfAvailable( .glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8))
GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8))
SliderSection( SliderSection(
intervalSettings: Binding( intervalSettings: Binding(
@@ -72,10 +50,7 @@ struct PostureSetupView: View {
} }
), ),
countdownSettings: nil, countdownSettings: nil,
enabled: Binding( enabled: $settingsManager.settings.postureTimer.enabled,
get: { settingsManager.settings.postureTimer.enabled },
set: { settingsManager.settings.postureTimer.enabled = $0 }
),
type: "Posture", type: "Posture",
previewFunc: showPreviewWindow previewFunc: showPreviewWindow
) )
@@ -90,35 +65,12 @@ struct PostureSetupView: View {
private func showPreviewWindow() { private func showPreviewWindow() {
guard let screen = NSScreen.main else { return } guard let screen = NSScreen.main else { return }
previewWindowController = PreviewWindowHelper.showPreview(
let window = NSWindow( on: screen,
contentRect: screen.frame, content: PostureReminderView(sizePercentage: settingsManager.settings.subtleReminderSize.percentage) { [weak previewWindowController] in
styleMask: [.borderless, .fullSizeContentView], previewWindowController?.window?.close()
backing: .buffered,
defer: false
)
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
} }
} }

View File

@@ -8,31 +8,35 @@
import SwiftUI import SwiftUI
struct SmartModeSetupView: View { struct SmartModeSetupView: View {
@ObservedObject var settingsManager: SettingsManager @Bindable var settingsManager: SettingsManager
@StateObject private var permissionManager = ScreenCapturePermissionManager.shared @State private var permissionManager = ScreenCapturePermissionManager.shared
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Fixed header section SetupHeader(icon: "brain.fill", title: "Smart Mode", color: .purple)
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") Text("Automatically manage timers based on your activity")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
}
.padding(.top, 20)
.padding(.bottom, 30) .padding(.bottom, 30)
Spacer() Spacer()
VStack(spacing: 24) { VStack(spacing: 24) {
// Auto-pause on fullscreen toggle fullscreenSection
idleSection
usageTrackingSection
}
.frame(maxWidth: 600)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
}
private var fullscreenSection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -42,33 +46,30 @@ struct SmartModeSetupView: View {
Text("Auto-pause on Fullscreen") Text("Auto-pause on Fullscreen")
.font(.headline) .font(.headline)
} }
Text( Text("Timers will automatically pause when you enter fullscreen mode (videos, games, presentations)")
"Timers will automatically pause when you enter fullscreen mode (videos, games, presentations)"
)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
Spacer() Spacer()
Toggle( Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnFullscreen)
"", .labelsHidden()
isOn: Binding( .onChange(of: settingsManager.settings.smartMode.autoPauseOnFullscreen) { _, newValue in
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 { if newValue {
permissionManager.requestAuthorizationIfNeeded() permissionManager.requestAuthorizationIfNeeded()
} }
} }
)
)
.labelsHidden()
} }
if settingsManager.settings.smartMode.autoPauseOnFullscreen, if settingsManager.settings.smartMode.autoPauseOnFullscreen,
permissionManager.authorizationStatus != .authorized permissionManager.authorizationStatus != .authorized {
{ permissionWarningView
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
}
private var permissionWarningView: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Label( Label(
permissionManager.authorizationStatus == .denied permissionManager.authorizationStatus == .denied
@@ -78,9 +79,7 @@ struct SmartModeSetupView: View {
) )
.foregroundStyle(.orange) .foregroundStyle(.orange)
Text( Text("macOS requires Screen Recording permission to detect other apps in fullscreen.")
"macOS requires Screen Recording permission to detect other apps in fullscreen."
)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -101,11 +100,8 @@ struct SmartModeSetupView: View {
} }
.padding(.top, 8) .padding(.top, 8)
} }
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
// Auto-pause on idle toggle with threshold slider private var idleSection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -115,62 +111,29 @@ struct SmartModeSetupView: View {
Text("Auto-pause on Idle") Text("Auto-pause on Idle")
.font(.headline) .font(.headline)
} }
Text( Text("Timers will pause when you're inactive for more than the threshold below")
"Timers will pause when you're inactive for more than the threshold below"
)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
Spacer() Spacer()
Toggle( Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnIdle)
"",
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() .labelsHidden()
} }
if settingsManager.settings.smartMode.autoPauseOnIdle { if settingsManager.settings.smartMode.autoPauseOnIdle {
VStack(alignment: .leading, spacing: 8) { ThresholdSlider(
HStack { label: "Idle Threshold:",
Text("Idle Threshold:") value: $settingsManager.settings.smartMode.idleThresholdMinutes,
.font(.subheadline) range: 1...30,
Spacer() unit: "min"
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() .padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8)) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
}
// Usage tracking toggle with reset threshold private var usageTrackingSection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -180,71 +143,60 @@ struct SmartModeSetupView: View {
Text("Track Usage Statistics") Text("Track Usage Statistics")
.font(.headline) .font(.headline)
} }
Text( Text("Monitor active and idle time, with automatic reset after the specified duration")
"Monitor active and idle time, with automatic reset after the specified duration"
)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
Spacer() Spacer()
Toggle( Toggle("", isOn: $settingsManager.settings.smartMode.trackUsage)
"",
isOn: Binding(
get: { settingsManager.settings.smartMode.trackUsage },
set: { newValue in
print("🔧 Smart Mode - Track usage changed to: \(newValue)")
settingsManager.settings.smartMode.trackUsage = newValue
}
)
)
.labelsHidden() .labelsHidden()
} }
if settingsManager.settings.smartMode.trackUsage { 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<Int>
var step: Int = 1
let unit: String
var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
Text("Reset After:") Text(label)
.font(.subheadline) .font(.subheadline)
Spacer() Spacer()
Text( Text("\(value) \(unit)")
"\(settingsManager.settings.smartMode.usageResetAfterMinutes) min"
)
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
Slider( Slider(
value: Binding( value: Binding(
get: { get: { Double(value) },
Double( set: { value = Int($0) }
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, in: Double(range.lowerBound)...Double(range.upperBound),
step: 15 step: Double(step)
) )
} }
.padding(.top, 8) .padding(.top, 8)
} }
} }
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
}
.frame(maxWidth: 600)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
}
}
#Preview { #Preview {
SmartModeSetupView(settingsManager: SettingsManager.shared) SmartModeSetupView(settingsManager: SettingsManager.shared)

View File

@@ -27,7 +27,8 @@ final class OnboardingNavigationTests: XCTestCase {
// MARK: - Navigation Tests // MARK: - Navigation Tests
func testOnboardingStartsAtWelcomePage() { 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 // Verify initial state
XCTAssertFalse(testEnv.settingsManager.settings.hasCompletedOnboarding) XCTAssertFalse(testEnv.settingsManager.settings.hasCompletedOnboarding)

View File

@@ -39,7 +39,7 @@ final class SettingsManagerTests: XCTestCase {
let defaults = AppSettings.defaults let defaults = AppSettings.defaults
XCTAssertTrue(defaults.lookAwayTimer.enabled) XCTAssertTrue(defaults.lookAwayTimer.enabled)
XCTAssertTrue(defaults.blinkTimer.enabled) XCTAssertFalse(defaults.blinkTimer.enabled) // Blink timer is disabled by default
XCTAssertTrue(defaults.postureTimer.enabled) XCTAssertTrue(defaults.postureTimer.enabled)
XCTAssertFalse(defaults.hasCompletedOnboarding) XCTAssertFalse(defaults.hasCompletedOnboarding)
} }
@@ -92,7 +92,7 @@ final class SettingsManagerTests: XCTestCase {
let expectation = XCTestExpectation(description: "Settings changed") let expectation = XCTestExpectation(description: "Settings changed")
var receivedSettings: AppSettings? var receivedSettings: AppSettings?
settingsManager.$settings settingsManager.settingsPublisher
.dropFirst() // Skip initial value .dropFirst() // Skip initial value
.sink { settings in .sink { settings in
receivedSettings = settings receivedSettings = settings
@@ -102,6 +102,7 @@ final class SettingsManagerTests: XCTestCase {
// Trigger change // Trigger change
settingsManager.settings.playSounds = !settingsManager.settings.playSounds settingsManager.settings.playSounds = !settingsManager.settings.playSounds
settingsManager.save()
await fulfillment(of: [expectation], timeout: 1.0) await fulfillment(of: [expectation], timeout: 1.0)
XCTAssertNotNil(receivedSettings) XCTAssertNotNil(receivedSettings)

View File

@@ -13,13 +13,18 @@ import XCTest
/// Enhanced mock settings manager with full control over state /// Enhanced mock settings manager with full control over state
@MainActor @MainActor
final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding { @Observable
@Published var settings: AppSettings final class EnhancedMockSettingsManager: SettingsProviding {
var settings: AppSettings
var settingsPublisher: Published<AppSettings>.Publisher { @ObservationIgnored
$settings private let _settingsSubject: CurrentValueSubject<AppSettings, Never>
var settingsPublisher: AnyPublisher<AppSettings, Never> {
_settingsSubject.eraseToAnyPublisher()
} }
@ObservationIgnored
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [ private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [
.lookAway: \.lookAwayTimer, .lookAway: \.lookAwayTimer,
.blink: \.blinkTimer, .blink: \.blinkTimer,
@@ -27,13 +32,18 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
] ]
// Track method calls for verification // Track method calls for verification
@ObservationIgnored
private(set) var saveCallCount = 0 private(set) var saveCallCount = 0
@ObservationIgnored
private(set) var saveImmediatelyCallCount = 0 private(set) var saveImmediatelyCallCount = 0
@ObservationIgnored
private(set) var loadCallCount = 0 private(set) var loadCallCount = 0
@ObservationIgnored
private(set) var resetToDefaultsCallCount = 0 private(set) var resetToDefaultsCallCount = 0
init(settings: AppSettings = .defaults) { init(settings: AppSettings = .defaults) {
self.settings = settings self.settings = settings
self._settingsSubject = CurrentValueSubject(settings)
} }
func timerConfiguration(for type: TimerType) -> TimerConfiguration { func timerConfiguration(for type: TimerType) -> TimerConfiguration {
@@ -48,6 +58,7 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
preconditionFailure("Unknown timer type: \(type)") preconditionFailure("Unknown timer type: \(type)")
} }
settings[keyPath: keyPath] = configuration settings[keyPath: keyPath] = configuration
_settingsSubject.send(settings)
} }
func allTimerConfigurations() -> [TimerType: TimerConfiguration] { func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
@@ -60,10 +71,12 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
func save() { func save() {
saveCallCount += 1 saveCallCount += 1
_settingsSubject.send(settings)
} }
func saveImmediately() { func saveImmediately() {
saveImmediatelyCallCount += 1 saveImmediatelyCallCount += 1
_settingsSubject.send(settings)
} }
func load() { func load() {
@@ -73,6 +86,7 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
func resetToDefaults() { func resetToDefaults() {
resetToDefaultsCallCount += 1 resetToDefaultsCallCount += 1
settings = .defaults settings = .defaults
_settingsSubject.send(settings)
} }
// Test helpers // Test helpers
@@ -82,6 +96,7 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
loadCallCount = 0 loadCallCount = 0
resetToDefaultsCallCount = 0 resetToDefaultsCallCount = 0
settings = .defaults settings = .defaults
_settingsSubject.send(settings)
} }
} }

View File

@@ -23,9 +23,8 @@ final class BlinkSetupViewTests: XCTestCase {
} }
func testBlinkSetupInitialization() { func testBlinkSetupInitialization() {
let view = BlinkSetupView( // Use real SettingsManager for view initialization test since @Bindable requires concrete type
settingsManager: testEnv.settingsManager as! SettingsManager let view = BlinkSetupView(settingsManager: SettingsManager.shared)
)
XCTAssertNotNil(view) XCTAssertNotNil(view)
} }

View File

@@ -23,10 +23,8 @@ final class GeneralSetupViewTests: XCTestCase {
} }
func testGeneralSetupInitialization() { func testGeneralSetupInitialization() {
let view = GeneralSetupView( // Use real SettingsManager for view initialization test since @Bindable requires concrete type
settingsManager: testEnv.settingsManager as! SettingsManager, let view = GeneralSetupView(settingsManager: SettingsManager.shared, isOnboarding: true)
isOnboarding: true
)
XCTAssertNotNil(view) XCTAssertNotNil(view)
} }

View File

@@ -23,9 +23,8 @@ final class LookAwaySetupViewTests: XCTestCase {
} }
func testLookAwaySetupInitialization() { func testLookAwaySetupInitialization() {
let view = LookAwaySetupView( // Use real SettingsManager for view initialization test since @Bindable requires concrete type
settingsManager: testEnv.settingsManager as! SettingsManager let view = LookAwaySetupView(settingsManager: SettingsManager.shared)
)
XCTAssertNotNil(view) XCTAssertNotNil(view)
} }

View File

@@ -23,9 +23,8 @@ final class PostureSetupViewTests: XCTestCase {
} }
func testPostureSetupInitialization() { func testPostureSetupInitialization() {
let view = PostureSetupView( // Use real SettingsManager for view initialization test since @Bindable requires concrete type
settingsManager: testEnv.settingsManager as! SettingsManager let view = PostureSetupView(settingsManager: SettingsManager.shared)
)
XCTAssertNotNil(view) XCTAssertNotNil(view)
} }