diff --git a/Gaze/Models/SettingsSection.swift b/Gaze/Models/SettingsSection.swift new file mode 100644 index 0000000..ef10429 --- /dev/null +++ b/Gaze/Models/SettingsSection.swift @@ -0,0 +1,44 @@ +// +// SettingsSection.swift +// Gaze +// +// Created by Mike Freno on 1/14/26. +// + +import Foundation + +enum SettingsSection: Int, CaseIterable, Identifiable { + case general = 0 + case lookAway = 1 + case blink = 2 + case posture = 3 + case enforceMode = 4 + case userTimers = 5 + case smartMode = 6 + + var id: Int { rawValue } + + var title: String { + switch self { + case .general: return "General" + case .lookAway: return "Look Away" + case .blink: return "Blink" + case .posture: return "Posture" + case .enforceMode: return "Enforce Mode" + case .userTimers: return "User Timers" + case .smartMode: return "Smart Mode" + } + } + + var iconName: String { + switch self { + case .general: return "gearshape.fill" + case .lookAway: return "eye.fill" + case .blink: return "eye.circle.fill" + case .posture: return "figure.stand" + case .enforceMode: return "video.fill" + case .userTimers: return "plus.circle" + case .smartMode: return "brain.fill" + } + } +} diff --git a/Gaze/Views/Containers/SettingsWindowView.swift b/Gaze/Views/Containers/SettingsWindowView.swift index b31862d..be52b0c 100644 --- a/Gaze/Views/Containers/SettingsWindowView.swift +++ b/Gaze/Views/Containers/SettingsWindowView.swift @@ -9,64 +9,92 @@ import SwiftUI struct SettingsWindowView: View { @ObservedObject var settingsManager: SettingsManager - @State private var currentTab: Int + @State private var selectedSection: SettingsSection init(settingsManager: SettingsManager, initialTab: Int = 0) { self.settingsManager = settingsManager - _currentTab = State(initialValue: initialTab) + _selectedSection = State(initialValue: SettingsSection(rawValue: initialTab) ?? .general) } var body: some View { VStack(spacing: 0) { - TabView(selection: $currentTab) { - LookAwaySetupView(settingsManager: settingsManager) - .tag(0) - .tabItem { - Label("Look Away", systemImage: "eye.fill") + if #available(macOS 15.0, *) { + TabView(selection: $selectedSection) { + Tab( + SettingsSection.general.title, + systemImage: SettingsSection.general.iconName, + value: SettingsSection.general + ) { + GeneralSetupView( + settingsManager: settingsManager, + isOnboarding: false + ) } - BlinkSetupView(settingsManager: settingsManager) - .tag(1) - .tabItem { - Label("Blink", systemImage: "eye.circle.fill") + Tab( + SettingsSection.lookAway.title, + systemImage: SettingsSection.lookAway.iconName, + value: SettingsSection.lookAway + ) { + LookAwaySetupView(settingsManager: settingsManager) } - PostureSetupView(settingsManager: settingsManager) - .tag(2) - .tabItem { - Label("Posture", systemImage: "figure.stand") + Tab( + SettingsSection.blink.title, systemImage: SettingsSection.blink.iconName, + value: SettingsSection.blink + ) { + BlinkSetupView(settingsManager: settingsManager) } - EnforceModeSetupView(settingsManager: settingsManager) - .tag(3) - .tabItem { - Label("Enforce Mode", systemImage: "video.fill") + Tab( + SettingsSection.posture.title, + systemImage: SettingsSection.posture.iconName, + value: SettingsSection.posture + ) { + PostureSetupView(settingsManager: settingsManager) } - UserTimersView( - userTimers: Binding( - get: { settingsManager.settings.userTimers }, - set: { settingsManager.settings.userTimers = $0 } - ) - ) - .tag(4) - .tabItem { - Label("User Timers", systemImage: "plus.circle") + Tab( + SettingsSection.userTimers.title, + systemImage: SettingsSection.userTimers.iconName, + value: SettingsSection.userTimers + ) { + UserTimersView( + userTimers: Binding( + get: { settingsManager.settings.userTimers }, + set: { settingsManager.settings.userTimers = $0 } + ) + ) + } + Tab( + SettingsSection.enforceMode.title, + systemImage: SettingsSection.enforceMode.iconName, + value: SettingsSection.enforceMode + ) { + EnforceModeSetupView(settingsManager: settingsManager) + } + + Tab( + SettingsSection.smartMode.title, + systemImage: SettingsSection.smartMode.iconName, + value: SettingsSection.smartMode + ) { + SmartModeSetupView(settingsManager: settingsManager) + } } - - SmartModeSetupView(settingsManager: settingsManager) - .tag(5) - .tabItem { - Label("Smart Mode", systemImage: "brain.fill") + .tabViewStyle(.sidebarAdaptable) + } else { + // Fallback for macOS 14 and earlier + NavigationSplitView { + List(SettingsSection.allCases, selection: $selectedSection) { section in + NavigationLink(value: section) { + Label(section.title, systemImage: section.iconName) + } } - - GeneralSetupView( - settingsManager: settingsManager, - isOnboarding: false - ) - .tag(6) - .tabItem { - Label("General", systemImage: "gearshape.fill") + .navigationTitle("Settings") + .listStyle(.sidebar) + } detail: { + detailView(for: selectedSection) } } @@ -92,24 +120,54 @@ struct SettingsWindowView: View { } #if APPSTORE .frame( - minWidth: 750, + minWidth: 1000, minHeight: 700 ) #else .frame( - minWidth: 750, + minWidth: 1000, minHeight: 900 ) #endif .onReceive( NotificationCenter.default.publisher(for: Notification.Name("SwitchToSettingsTab")) ) { notification in - if let tab = notification.object as? Int { - currentTab = tab + if let tab = notification.object as? Int, + let section = SettingsSection(rawValue: tab) + { + selectedSection = section } } } + @ViewBuilder + private func detailView(for section: SettingsSection) -> some View { + switch section { + case .general: + GeneralSetupView( + settingsManager: settingsManager, + isOnboarding: false + ) + case .lookAway: + LookAwaySetupView(settingsManager: settingsManager) + case .blink: + BlinkSetupView(settingsManager: settingsManager) + case .posture: + PostureSetupView(settingsManager: settingsManager) + case .enforceMode: + EnforceModeSetupView(settingsManager: settingsManager) + case .userTimers: + UserTimersView( + userTimers: Binding( + get: { settingsManager.settings.userTimers }, + set: { settingsManager.settings.userTimers = $0 } + ) + ) + case .smartMode: + SmartModeSetupView(settingsManager: settingsManager) + } + } + private func closeWindow() { if let window = NSApplication.shared.windows.first(where: { $0.title == "Settings" }) { window.close() diff --git a/Gaze/Views/MenuBar/MenuBarContentView.swift b/Gaze/Views/MenuBar/MenuBarContentView.swift index b7b9f3f..ddf3f51 100644 --- a/Gaze/Views/MenuBar/MenuBarContentView.swift +++ b/Gaze/Views/MenuBar/MenuBarContentView.swift @@ -143,7 +143,6 @@ struct MenuBarContentView: View { Divider() - // Quit Button(action: onQuit) { HStack { Image(systemName: "power") @@ -190,7 +189,8 @@ struct MenuBarContentView: View { .padding(.top, 8) // Show all timers using unified identifier system - ForEach(getSortedTimerIdentifiers(timerEngine: timerEngine), id: \.self) { identifier in + ForEach(getSortedTimerIdentifiers(timerEngine: timerEngine), id: \.self) { + identifier in if timerEngine.timerStates[identifier] != nil { TimerStatusRowWithIndividualControls( identifier: identifier, @@ -294,7 +294,7 @@ struct MenuBarContentView: View { let activeStates = timerEngine.timerStates.values.filter { $0.isActive } return !activeStates.isEmpty && activeStates.allSatisfy { $0.isPaused } } - + private func getSortedTimerIdentifiers(timerEngine: TimerEngine) -> [TimerIdentifier] { return timerEngine.timerStates.keys.sorted { id1, id2 in // Sort built-in timers before user timers @@ -333,16 +333,17 @@ struct TimerStatusRowWithIndividualControls: View { private var isPaused: Bool { return state?.isPaused ?? false } - + private var displayName: String { switch identifier { case .builtIn(let type): return type.displayName case .user(let id): - return settingsManager.settings.userTimers.first(where: { $0.id == id })?.title ?? "User Timer" + return settingsManager.settings.userTimers.first(where: { $0.id == id })?.title + ?? "User Timer" } } - + private var iconName: String { switch identifier { case .builtIn(let type): @@ -351,7 +352,7 @@ struct TimerStatusRowWithIndividualControls: View { return "clock.fill" } } - + private var color: Color { switch identifier { case .builtIn(let type): @@ -361,16 +362,18 @@ struct TimerStatusRowWithIndividualControls: View { case .posture: return .orange } case .user(let id): - return settingsManager.settings.userTimers.first(where: { $0.id == id })?.color ?? .purple + return settingsManager.settings.userTimers.first(where: { $0.id == id })?.color + ?? .purple } } - + private var tooltipText: String { switch identifier { case .builtIn(let type): return type.tooltipText case .user(let id): - guard let timer = settingsManager.settings.userTimers.first(where: { $0.id == id }) else { + guard let timer = settingsManager.settings.userTimers.first(where: { $0.id == id }) + else { return "User Timer" } let typeText = timer.type == .subtle ? "Subtle" : "Overlay" @@ -379,7 +382,7 @@ struct TimerStatusRowWithIndividualControls: View { return "\(typeText) timer - \(durationText)\(statusText)" } } - + private var userTimer: UserTimer? { if case .user(let id) = identifier { return settingsManager.settings.userTimers.first(where: { $0.id == id }) @@ -440,7 +443,9 @@ struct TimerStatusRowWithIndividualControls: View { colorScheme: colorScheme ) .help("Trigger \(displayName) reminder now (dev)") - .accessibilityIdentifier("trigger_\(displayName.replacingOccurrences(of: " ", with: "_"))") + .accessibilityIdentifier( + "trigger_\(displayName.replacingOccurrences(of: " ", with: "_"))" + ) .onHover { hovering in isHoveredDevTrigger = hovering } diff --git a/Gaze/Views/Setup/SmartModeSetupView.swift b/Gaze/Views/Setup/SmartModeSetupView.swift index df800e5..059bc04 100644 --- a/Gaze/Views/Setup/SmartModeSetupView.swift +++ b/Gaze/Views/Setup/SmartModeSetupView.swift @@ -9,7 +9,7 @@ import SwiftUI struct SmartModeSetupView: View { @ObservedObject var settingsManager: SettingsManager - + var body: some View { VStack(spacing: 0) { // Fixed header section @@ -17,140 +17,177 @@ struct SmartModeSetupView: View { 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) - - // Vertically centered content + Spacer() - + VStack(spacing: 24) { // Auto-pause on fullscreen toggle VStack(alignment: .leading, spacing: 12) { - Toggle(isOn: Binding( - get: { settingsManager.settings.smartMode.autoPauseOnFullscreen }, - set: { settingsManager.settings.smartMode.autoPauseOnFullscreen = $0 } - )) { - HStack { - Image(systemName: "arrow.up.left.and.arrow.down.right") - .foregroundColor(.blue) - Text("Auto-pause on Fullscreen") - .font(.headline) + 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: { + settingsManager.settings.smartMode.autoPauseOnFullscreen = $0 + } + ) + ) + .labelsHidden() } - .toggleStyle(.switch) - - Text("Timers will automatically pause when you enter fullscreen mode (videos, games, presentations)") - .font(.caption) - .foregroundColor(.secondary) - .padding(.leading, 28) } .padding() .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8)) - + // Auto-pause on idle toggle with threshold slider VStack(alignment: .leading, spacing: 12) { - Toggle(isOn: Binding( - get: { settingsManager.settings.smartMode.autoPauseOnIdle }, - set: { settingsManager.settings.smartMode.autoPauseOnIdle = $0 } - )) { - HStack { - Image(systemName: "moon.zzz.fill") - .foregroundColor(.indigo) - Text("Auto-pause on Idle") - .font(.headline) + 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: { settingsManager.settings.smartMode.autoPauseOnIdle = $0 } + ) + ) + .labelsHidden() } - .toggleStyle(.switch) - - Text("Timers will pause when you're inactive for more than the threshold below") - .font(.caption) - .foregroundColor(.secondary) - .padding(.leading, 28) - + 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) + Text( + "\(settingsManager.settings.smartMode.idleThresholdMinutes) min" + ) + .font(.subheadline) + .foregroundColor(.secondary) } - + Slider( value: Binding( - get: { Double(settingsManager.settings.smartMode.idleThresholdMinutes) }, - set: { settingsManager.settings.smartMode.idleThresholdMinutes = Int($0) } + get: { + Double( + settingsManager.settings.smartMode.idleThresholdMinutes) + }, + set: { + settingsManager.settings.smartMode.idleThresholdMinutes = + Int($0) + } ), in: 1...30, step: 1 ) } .padding(.top, 8) - .padding(.leading, 28) } } .padding() .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8)) - + // Usage tracking toggle with reset threshold VStack(alignment: .leading, spacing: 12) { - Toggle(isOn: Binding( - get: { settingsManager.settings.smartMode.trackUsage }, - set: { settingsManager.settings.smartMode.trackUsage = $0 } - )) { - HStack { - Image(systemName: "chart.line.uptrend.xyaxis") - .foregroundColor(.green) - Text("Track Usage Statistics") - .font(.headline) + 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: { settingsManager.settings.smartMode.trackUsage = $0 } + ) + ) + .labelsHidden() } - .toggleStyle(.switch) - - Text("Monitor active and idle time, with automatic reset after the specified duration") - .font(.caption) - .foregroundColor(.secondary) - .padding(.leading, 28) - + 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) + Text( + "\(settingsManager.settings.smartMode.usageResetAfterMinutes) min" + ) + .font(.subheadline) + .foregroundColor(.secondary) } - + Slider( value: Binding( - get: { Double(settingsManager.settings.smartMode.usageResetAfterMinutes) }, - set: { settingsManager.settings.smartMode.usageResetAfterMinutes = Int($0) } + get: { + Double( + settingsManager.settings.smartMode + .usageResetAfterMinutes) + }, + set: { + settingsManager.settings.smartMode.usageResetAfterMinutes = + Int($0) + } ), in: 15...240, step: 15 ) } .padding(.top, 8) - .padding(.leading, 28) } } .padding() .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8)) } .frame(maxWidth: 600) - + Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity)