general:add settings window

This commit is contained in:
Michael Freno
2026-01-08 20:26:49 -05:00
parent 4b3a1f99c9
commit a0962e596a
5 changed files with 239 additions and 51 deletions

View File

@@ -11,16 +11,20 @@ import Combine
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
var timerEngine: TimerEngine?
private var statusItem: NSStatusItem?
private var popover: NSPopover?
private var timerEngine: TimerEngine?
private var settingsManager: SettingsManager?
private var reminderWindowController: NSWindowController?
private var settingsWindowController: NSWindowController?
private var cancellables = Set<AnyCancellable>()
private var timerStateBeforeSleep: [TimerType: Date] = [:]
private var hasStartedTimers = false
func applicationDidFinishLaunching(_ notification: Notification) {
// Set activation policy to hide dock icon
NSApplication.shared.setActivationPolicy(.accessory)
settingsManager = SettingsManager.shared
timerEngine = TimerEngine(settingsManager: settingsManager!)
@@ -38,6 +42,44 @@ class AppDelegate: NSObject, NSApplicationDelegate {
startTimers()
}
private func setupMenuBar() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
button.image = NSImage(systemSymbolName: "eye.fill", accessibilityDescription: "Gaze")
button.action = #selector(togglePopover)
button.target = self
}
}
@objc private func togglePopover() {
if let popover = popover, popover.isShown {
popover.close()
} else {
showPopover()
}
}
private func showPopover() {
let popover = NSPopover()
popover.contentSize = NSSize(width: 300, height: 400)
popover.behavior = .transient
popover.contentViewController = NSHostingController(
rootView: MenuBarContentView(
timerEngine: timerEngine!,
settingsManager: settingsManager!,
onQuit: { NSApplication.shared.terminate(nil) },
onOpenSettings: { [weak self] in self?.openSettings() }
)
)
if let button = statusItem?.button {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}
self.popover = popover
}
private func startTimers() {
guard !hasStartedTimers else { return }
hasStartedTimers = true
@@ -50,6 +92,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
.sink { [weak self] settings in
if settings.hasCompletedOnboarding {
self?.startTimers()
} else if self?.hasStartedTimers == true {
// Restart timers when settings change (only if already started)
self?.timerEngine?.start()
}
}
.store(in: &cancellables)
@@ -111,43 +156,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
timerEngine.resume()
}
private func setupMenuBar() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
button.image = NSImage(systemSymbolName: "eye.fill", accessibilityDescription: "Gaze")
button.action = #selector(togglePopover)
button.target = self
}
}
@objc private func togglePopover() {
if let popover = popover, popover.isShown {
popover.close()
} else {
showPopover()
}
}
private func showPopover() {
let popover = NSPopover()
popover.contentSize = NSSize(width: 300, height: 400)
popover.behavior = .transient
popover.contentViewController = NSHostingController(
rootView: MenuBarContentView(
timerEngine: timerEngine!,
settingsManager: settingsManager!,
onQuit: { NSApplication.shared.terminate(nil) }
)
)
if let button = statusItem?.button {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}
self.popover = popover
}
private func observeReminderEvents() {
timerEngine?.$activeReminder
.sink { [weak self] reminder in
@@ -218,4 +226,45 @@ class AppDelegate: NSObject, NSApplicationDelegate {
func getMenuBarIconPosition() -> NSRect? {
return statusItem?.button?.window?.frame
}
// Public method to open settings window
func openSettings() {
// If window already exists, just bring it to front
if let existingWindow = settingsWindowController?.window {
existingWindow.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 550),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.title = "Settings"
window.center()
window.setFrameAutosaveName("SettingsWindow")
window.isReleasedWhenClosed = false
window.contentView = NSHostingView(
rootView: SettingsWindowView(settingsManager: settingsManager!)
)
let windowController = NSWindowController(window: window)
windowController.showWindow(nil)
settingsWindowController = windowController
NSApp.activate(ignoringOtherApps: true)
// Observe when window is closed to clean up reference
NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: window,
queue: .main
) { [weak self] _ in
self?.settingsWindowController = nil
}
}
}

View File

@@ -46,6 +46,7 @@ struct MenuBarContentView: View {
@ObservedObject var timerEngine: TimerEngine
@ObservedObject var settingsManager: SettingsManager
var onQuit: () -> Void
var onOpenSettings: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -108,7 +109,7 @@ struct MenuBarContentView: View {
.buttonStyle(MenuBarHoverButtonStyle())
Button(action: {
// TODO: Open settings window
onOpenSettings()
}) {
HStack {
Image(systemName: "gearshape")
@@ -230,6 +231,7 @@ struct TimerStatusRow: View {
MenuBarContentView(
timerEngine: timerEngine,
settingsManager: settingsManager,
onQuit: {}
onQuit: {},
onOpenSettings: {}
)
}

View File

@@ -102,7 +102,7 @@ struct OnboardingContainerView: View {
minWidth: 100, maxWidth: .infinity, minHeight: 44,
maxHeight: 44, alignment: .center
)
.foregroundColor(.black)
.foregroundColor(.primary)
}
.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 10))
}

View File

@@ -0,0 +1,137 @@
//
// SettingsWindowView.swift
// Gaze
//
// Created by Mike Freno on 1/8/26.
//
import SwiftUI
struct SettingsWindowView: View {
@ObservedObject var settingsManager: SettingsManager
@State private var currentTab = 0
@State private var lookAwayEnabled: Bool
@State private var lookAwayIntervalMinutes: Int
@State private var lookAwayCountdownSeconds: Int
@State private var blinkEnabled: Bool
@State private var blinkIntervalMinutes: Int
@State private var postureEnabled: Bool
@State private var postureIntervalMinutes: Int
@State private var launchAtLogin: Bool
init(settingsManager: SettingsManager) {
self.settingsManager = settingsManager
_lookAwayEnabled = State(initialValue: settingsManager.settings.lookAwayTimer.enabled)
_lookAwayIntervalMinutes = State(initialValue: settingsManager.settings.lookAwayTimer.intervalSeconds / 60)
_lookAwayCountdownSeconds = State(initialValue: settingsManager.settings.lookAwayCountdownSeconds)
_blinkEnabled = State(initialValue: settingsManager.settings.blinkTimer.enabled)
_blinkIntervalMinutes = State(initialValue: settingsManager.settings.blinkTimer.intervalSeconds / 60)
_postureEnabled = State(initialValue: settingsManager.settings.postureTimer.enabled)
_postureIntervalMinutes = State(initialValue: settingsManager.settings.postureTimer.intervalSeconds / 60)
_launchAtLogin = State(initialValue: settingsManager.settings.launchAtLogin)
}
var body: some View {
VStack(spacing: 0) {
TabView(selection: $currentTab) {
LookAwaySetupView(
enabled: $lookAwayEnabled,
intervalMinutes: $lookAwayIntervalMinutes,
countdownSeconds: $lookAwayCountdownSeconds
)
.tag(0)
.tabItem {
Label("Look Away", systemImage: "eye.fill")
}
BlinkSetupView(
enabled: $blinkEnabled,
intervalMinutes: $blinkIntervalMinutes
)
.tag(1)
.tabItem {
Label("Blink", systemImage: "eye.circle.fill")
}
PostureSetupView(
enabled: $postureEnabled,
intervalMinutes: $postureIntervalMinutes
)
.tag(2)
.tabItem {
Label("Posture", systemImage: "figure.stand")
}
SettingsOnboardingView(
launchAtLogin: $launchAtLogin
)
.tag(3)
.tabItem {
Label("General", systemImage: "gearshape.fill")
}
}
.padding()
Divider()
HStack {
Spacer()
Button("Cancel") {
closeWindow()
}
.keyboardShortcut(.escape)
Button("Apply") {
applySettings()
closeWindow()
}
.keyboardShortcut(.return)
.buttonStyle(.borderedProminent)
}
.padding()
}
.frame(width: 600, height: 550)
}
private func applySettings() {
settingsManager.settings.lookAwayTimer = TimerConfiguration(
enabled: lookAwayEnabled,
intervalSeconds: lookAwayIntervalMinutes * 60
)
settingsManager.settings.lookAwayCountdownSeconds = lookAwayCountdownSeconds
settingsManager.settings.blinkTimer = TimerConfiguration(
enabled: blinkEnabled,
intervalSeconds: blinkIntervalMinutes * 60
)
settingsManager.settings.postureTimer = TimerConfiguration(
enabled: postureEnabled,
intervalSeconds: postureIntervalMinutes * 60
)
settingsManager.settings.launchAtLogin = launchAtLogin
do {
if launchAtLogin {
try LaunchAtLoginManager.enable()
} else {
try LaunchAtLoginManager.disable()
}
} catch {
print("Failed to set launch at login: \(error)")
}
}
private func closeWindow() {
if let window = NSApplication.shared.windows.first(where: { $0.title == "Settings" }) {
window.close()
}
}
}
#Preview {
SettingsWindowView(settingsManager: SettingsManager.shared)
}