general:add settings window
This commit is contained in:
@@ -11,16 +11,20 @@ import Combine
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
var timerEngine: TimerEngine?
|
||||||
private var statusItem: NSStatusItem?
|
private var statusItem: NSStatusItem?
|
||||||
private var popover: NSPopover?
|
private var popover: NSPopover?
|
||||||
private var timerEngine: TimerEngine?
|
|
||||||
private var settingsManager: SettingsManager?
|
private var settingsManager: SettingsManager?
|
||||||
private var reminderWindowController: NSWindowController?
|
private var reminderWindowController: NSWindowController?
|
||||||
|
private var settingsWindowController: NSWindowController?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var timerStateBeforeSleep: [TimerType: Date] = [:]
|
private var timerStateBeforeSleep: [TimerType: Date] = [:]
|
||||||
private var hasStartedTimers = false
|
private var hasStartedTimers = false
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
// Set activation policy to hide dock icon
|
||||||
|
NSApplication.shared.setActivationPolicy(.accessory)
|
||||||
|
|
||||||
settingsManager = SettingsManager.shared
|
settingsManager = SettingsManager.shared
|
||||||
timerEngine = TimerEngine(settingsManager: settingsManager!)
|
timerEngine = TimerEngine(settingsManager: settingsManager!)
|
||||||
|
|
||||||
@@ -38,6 +42,44 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
startTimers()
|
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() {
|
private func startTimers() {
|
||||||
guard !hasStartedTimers else { return }
|
guard !hasStartedTimers else { return }
|
||||||
hasStartedTimers = true
|
hasStartedTimers = true
|
||||||
@@ -50,6 +92,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
.sink { [weak self] settings in
|
.sink { [weak self] settings in
|
||||||
if settings.hasCompletedOnboarding {
|
if settings.hasCompletedOnboarding {
|
||||||
self?.startTimers()
|
self?.startTimers()
|
||||||
|
} else if self?.hasStartedTimers == true {
|
||||||
|
// Restart timers when settings change (only if already started)
|
||||||
|
self?.timerEngine?.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
@@ -111,43 +156,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
timerEngine.resume()
|
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() {
|
private func observeReminderEvents() {
|
||||||
timerEngine?.$activeReminder
|
timerEngine?.$activeReminder
|
||||||
.sink { [weak self] reminder in
|
.sink { [weak self] reminder in
|
||||||
@@ -218,4 +226,45 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
func getMenuBarIconPosition() -> NSRect? {
|
func getMenuBarIconPosition() -> NSRect? {
|
||||||
return statusItem?.button?.window?.frame
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ struct MenuBarContentView: View {
|
|||||||
@ObservedObject var timerEngine: TimerEngine
|
@ObservedObject var timerEngine: TimerEngine
|
||||||
@ObservedObject var settingsManager: SettingsManager
|
@ObservedObject var settingsManager: SettingsManager
|
||||||
var onQuit: () -> Void
|
var onQuit: () -> Void
|
||||||
|
var onOpenSettings: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
@@ -108,7 +109,7 @@ struct MenuBarContentView: View {
|
|||||||
.buttonStyle(MenuBarHoverButtonStyle())
|
.buttonStyle(MenuBarHoverButtonStyle())
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
// TODO: Open settings window
|
onOpenSettings()
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "gearshape")
|
Image(systemName: "gearshape")
|
||||||
@@ -230,6 +231,7 @@ struct TimerStatusRow: View {
|
|||||||
MenuBarContentView(
|
MenuBarContentView(
|
||||||
timerEngine: timerEngine,
|
timerEngine: timerEngine,
|
||||||
settingsManager: settingsManager,
|
settingsManager: settingsManager,
|
||||||
onQuit: {}
|
onQuit: {},
|
||||||
|
onOpenSettings: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ struct OnboardingContainerView: View {
|
|||||||
minWidth: 100, maxWidth: .infinity, minHeight: 44,
|
minWidth: 100, maxWidth: .infinity, minHeight: 44,
|
||||||
maxHeight: 44, alignment: .center
|
maxHeight: 44, alignment: .center
|
||||||
)
|
)
|
||||||
.foregroundColor(.black)
|
.foregroundColor(.primary)
|
||||||
}
|
}
|
||||||
.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 10))
|
.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,24 +9,24 @@ import SwiftUI
|
|||||||
|
|
||||||
struct SettingsOnboardingView: View {
|
struct SettingsOnboardingView: View {
|
||||||
@Binding var launchAtLogin: Bool
|
@Binding var launchAtLogin: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: "gearshape.fill")
|
Image(systemName: "gearshape.fill")
|
||||||
.font(.system(size: 80))
|
.font(.system(size: 80))
|
||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
|
|
||||||
Text("Final Settings")
|
Text("Final Settings")
|
||||||
.font(.system(size: 36, weight: .bold))
|
.font(.system(size: 36, weight: .bold))
|
||||||
|
|
||||||
Text("Configure app preferences and support the project")
|
Text("Configure app preferences and support the project")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.horizontal, 40)
|
.padding(.horizontal, 40)
|
||||||
|
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
// Launch at Login Toggle
|
// Launch at Login Toggle
|
||||||
HStack {
|
HStack {
|
||||||
@@ -46,13 +46,13 @@ struct SettingsOnboardingView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.glassEffect(.regular, in: .rect(cornerRadius: 12))
|
.glassEffect(.regular, in: .rect(cornerRadius: 12))
|
||||||
|
|
||||||
// Links Section
|
// Links Section
|
||||||
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
|
// GitHub Link
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if let url = URL(string: "https://github.com/mikefreno/Gaze") {
|
if let url = URL(string: "https://github.com/mikefreno/Gaze") {
|
||||||
@@ -79,7 +79,7 @@ struct SettingsOnboardingView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 10))
|
.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 10))
|
||||||
|
|
||||||
// Buy Me a Coffee
|
// Buy Me a Coffee
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if let url = URL(string: "https://buymeacoffee.com/placeholder") {
|
if let url = URL(string: "https://buymeacoffee.com/placeholder") {
|
||||||
@@ -112,14 +112,14 @@ struct SettingsOnboardingView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(width: 600, height: 450)
|
.frame(width: 600, height: 450)
|
||||||
.padding()
|
.padding()
|
||||||
.background(.clear)
|
.background(.clear)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyLaunchAtLoginSetting(enabled: Bool) {
|
private func applyLaunchAtLoginSetting(enabled: Bool) {
|
||||||
do {
|
do {
|
||||||
if enabled {
|
if enabled {
|
||||||
|
|||||||
137
Gaze/Views/SettingsWindowView.swift
Normal file
137
Gaze/Views/SettingsWindowView.swift
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user