From 368f0c88cce049d7f7a5c2cebe41ea36ed293cfa Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 9 Jan 2026 18:36:11 -0500 Subject: [PATCH] feat: user timer, ui improvements --- Gaze/AppDelegate.swift | 2 +- Gaze/GazeApp.swift | 2 + Gaze/Models/TimerState.swift | 9 +- Gaze/Models/UserTimer.swift | 2 +- Gaze/Services/TimerEngine.swift | 87 ++++++ Gaze/Views/MenuBar/MenuBarContentView.swift | 89 +++++- Gaze/Views/Onboarding/BlinkSetupView.swift | 2 +- Gaze/Views/Onboarding/LookAwaySetupView.swift | 2 +- .../Onboarding/OnboardingContainerView.swift | 4 + Gaze/Views/Onboarding/PostureSetupView.swift | 2 +- .../Onboarding/SettingsOnboardingView.swift | 28 +- Gaze/Views/Onboarding/UserTimersView.swift | 295 ++++++++++++++++++ Gaze/Views/SettingsWindowView.swift | 64 ++-- 13 files changed, 556 insertions(+), 32 deletions(-) create mode 100644 Gaze/Views/Onboarding/UserTimersView.swift diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index 3852eb2..c1220c4 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -200,7 +200,7 @@ private func showReminderWindow(_ content: AnyView) { } let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 600, height: 550), + contentRect: NSRect(x: 0, y: 0, width: 700, height: 700), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false diff --git a/Gaze/GazeApp.swift b/Gaze/GazeApp.swift index fafadb3..3fb2216 100644 --- a/Gaze/GazeApp.swift +++ b/Gaze/GazeApp.swift @@ -31,6 +31,8 @@ struct GazeApp: App { } } .windowStyle(.hiddenTitleBar) + .windowResizability(.contentSize) + .defaultSize(width: 700, height: 700) .commands { CommandGroup(replacing: .newItem) { } } diff --git a/Gaze/Models/TimerState.swift b/Gaze/Models/TimerState.swift index 2f53462..e257975 100644 --- a/Gaze/Models/TimerState.swift +++ b/Gaze/Models/TimerState.swift @@ -7,7 +7,7 @@ import Foundation -struct TimerState { +struct TimerState: Equatable { let type: TimerType var remainingSeconds: Int var isPaused: Bool @@ -21,4 +21,11 @@ struct TimerState { self.isActive = isActive self.targetDate = Date().addingTimeInterval(Double(intervalSeconds)) } + + static func == (lhs: TimerState, rhs: TimerState) -> Bool { + lhs.type == rhs.type && lhs.remainingSeconds == rhs.remainingSeconds + && lhs.isPaused == rhs.isPaused && lhs.isActive == rhs.isActive + && lhs.targetDate.timeIntervalSince1970.rounded() + == rhs.targetDate.timeIntervalSince1970.rounded() + } } diff --git a/Gaze/Models/UserTimer.swift b/Gaze/Models/UserTimer.swift index 0dedff5..62f1b41 100644 --- a/Gaze/Models/UserTimer.swift +++ b/Gaze/Models/UserTimer.swift @@ -8,7 +8,7 @@ import Foundation /// Represents a user-defined timer with customizable properties -struct UserTimer: Codable, Equatable { +struct UserTimer: Codable, Equatable, Identifiable { let id: String var type: UserTimerType var timeOnScreenSeconds: Int diff --git a/Gaze/Services/TimerEngine.swift b/Gaze/Services/TimerEngine.swift index 2aca8bf..c51a83f 100644 --- a/Gaze/Services/TimerEngine.swift +++ b/Gaze/Services/TimerEngine.swift @@ -12,6 +12,9 @@ import Foundation class TimerEngine: ObservableObject { @Published var timerStates: [TimerType: TimerState] = [:] @Published var activeReminder: ReminderEvent? + + // Track user timer states separately + private var userTimerStates: [String: TimerState] = [:] private var timerSubscription: AnyCancellable? private let settingsManager: SettingsManager @@ -35,6 +38,11 @@ class TimerEngine: ObservableObject { } } + // Start user timers + for userTimer in settingsManager.settings.userTimers { + startUserTimer(userTimer) + } + timerSubscription = Timer.publish(every: 1.0, on: .main, in: .common) .autoconnect() .sink { [weak self] _ in @@ -48,6 +56,7 @@ class TimerEngine: ObservableObject { timerSubscription?.cancel() timerSubscription = nil timerStates.removeAll() + userTimerStates.removeAll() } func pause() { @@ -87,6 +96,7 @@ class TimerEngine: ObservableObject { private func handleTick() { guard activeReminder == nil else { return } + // Handle regular timers first for (type, state) in timerStates { guard state.isActive && !state.isPaused else { continue } // prevent overshoot - in case user closes laptop while timer is running, we don't want to @@ -110,6 +120,36 @@ class TimerEngine: ObservableObject { break } } + + // Handle user timers + handleUserTimerTicks() + } + + private func handleUserTimerTicks() { + for (id, state) in userTimerStates { + if !state.isActive || state.isPaused { continue } + + // Update user timer countdown + userTimerStates[id]?.remainingSeconds -= 1 + + if let updatedState = userTimerStates[id], updatedState.remainingSeconds <= 0 { + // Trigger the user timer reminder + triggerUserTimerReminder(forId: id) + } + } + } + + private func triggerUserTimerReminder(forId id: String) { + // Here we'd implement how to show a subtle reminder for user timers + // For now, just reset the timer + if let userTimer = settingsManager.settings.userTimers.first(where: { $0.id == id }) { + userTimerStates[id] = TimerState( + type: .lookAway, // Placeholder - user timers won't use this + intervalSeconds: userTimer.timeOnScreenSeconds, + isPaused: false, + isActive: true + ) + } } func triggerReminder(for type: TimerType) { @@ -124,11 +164,44 @@ class TimerEngine: ObservableObject { activeReminder = .postureTriggered } } + + // User timer management methods + func startUserTimer(_ userTimer: UserTimer) { + userTimerStates[userTimer.id] = TimerState( + type: .lookAway, // Placeholder - we'll need to make this more flexible + intervalSeconds: userTimer.timeOnScreenSeconds, + isPaused: false, + isActive: true + ) + } + + func stopUserTimer(_ userTimerId: String) { + userTimerStates[userTimerId] = nil + } + + func pauseUserTimer(_ userTimerId: String) { + if var state = userTimerStates[userTimerId] { + state.isPaused = true + userTimerStates[userTimerId] = state + } + } + + func resumeUserTimer(_ userTimerId: String) { + if var state = userTimerStates[userTimerId] { + state.isPaused = false + userTimerStates[userTimerId] = state + } + } func getTimeRemaining(for type: TimerType) -> TimeInterval { guard let state = timerStates[type] else { return 0 } return TimeInterval(state.remainingSeconds) } + + func getUserTimeRemaining(for userId: String) -> TimeInterval { + guard let state = userTimerStates[userId] else { return 0 } + return TimeInterval(state.remainingSeconds) + } func getFormattedTimeRemaining(for type: TimerType) -> String { let seconds = Int(getTimeRemaining(for: type)) @@ -143,4 +216,18 @@ class TimerEngine: ObservableObject { return String(format: "%d:%02d", minutes, remainingSeconds) } } + + func getUserFormattedTimeRemaining(for userId: String) -> String { + let seconds = Int(getUserTimeRemaining(for: userId)) + let minutes = seconds / 60 + let remainingSeconds = seconds % 60 + + if minutes >= 60 { + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + return String(format: "%d:%02d:%02d", hours, remainingMinutes, remainingSeconds) + } else { + return String(format: "%d:%02d", minutes, remainingSeconds) + } + } } diff --git a/Gaze/Views/MenuBar/MenuBarContentView.swift b/Gaze/Views/MenuBar/MenuBarContentView.swift index 719b9c1..18c697d 100644 --- a/Gaze/Views/MenuBar/MenuBarContentView.swift +++ b/Gaze/Views/MenuBar/MenuBarContentView.swift @@ -97,6 +97,17 @@ struct MenuBarContentView: View { ) } } + + // Show user timers if any exist + ForEach(settingsManager.settings.userTimers, id: \.id) { userTimer in + UserTimerStatusRow( + timer: userTimer, + state: nil, // We'll implement proper state tracking later + onTap: { + onOpenSettingsTab(3) // Switch to User Timers tab + } + ) + } } .padding(.bottom, 8) @@ -316,6 +327,82 @@ struct InactiveTimerRow: View { } } +struct UserTimerStatusRow: View { + let timer: UserTimer + let state: TimerState? + var onTap: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: onTap) { + HStack { + Image(systemName: "clock.fill") + .foregroundColor(.purple) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(timer.message ?? "Custom Timer") + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + + if let state = state { + Text(timeRemaining(state)) + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + } else { + Text("Not active") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + Image(systemName: timer.type == .subtle ? "eye.circle" : "rectangle.on.rectangle") + .font(.caption) + .foregroundColor(.secondary) + .padding(6) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + .glassEffect( + isHovered ? .regular.tint(.purple.opacity(0.5)) : .regular, + in: .rect(cornerRadius: 6) + ) + .padding(.horizontal, 8) + .onHover { hovering in + isHovered = hovering + } + .help(tooltipText) + } + + private var tooltipText: String { + let typeText = timer.type == .subtle ? "Subtle" : "Overlay" + let durationText = "\(timer.timeOnScreenSeconds)s on screen" + return "\(typeText) timer - \(durationText)" + } + + private func timeRemaining(_ state: TimerState) -> String { + let seconds = state.remainingSeconds + let minutes = seconds / 60 + let remainingSeconds = seconds % 60 + + if minutes >= 60 { + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + return String(format: "%dh %dm", hours, remainingMinutes) + } else if minutes > 0 { + return String(format: "%dm %ds", minutes, remainingSeconds) + } else { + return String(format: "%ds", remainingSeconds) + } + } +} + #Preview("Menu Bar Content") { let settingsManager = SettingsManager.shared let timerEngine = TimerEngine(settingsManager: settingsManager) @@ -326,4 +413,4 @@ struct InactiveTimerRow: View { onOpenSettings: {}, onOpenSettingsTab: { _ in } ) -} +} \ No newline at end of file diff --git a/Gaze/Views/Onboarding/BlinkSetupView.swift b/Gaze/Views/Onboarding/BlinkSetupView.swift index 005c9a4..e35449f 100644 --- a/Gaze/Views/Onboarding/BlinkSetupView.swift +++ b/Gaze/Views/Onboarding/BlinkSetupView.swift @@ -92,7 +92,7 @@ struct BlinkSetupView: View { Spacer() } - .frame(width: 600, height: 450) + .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() .background(.clear) } diff --git a/Gaze/Views/Onboarding/LookAwaySetupView.swift b/Gaze/Views/Onboarding/LookAwaySetupView.swift index 406b0aa..c41445d 100644 --- a/Gaze/Views/Onboarding/LookAwaySetupView.swift +++ b/Gaze/Views/Onboarding/LookAwaySetupView.swift @@ -110,7 +110,7 @@ struct LookAwaySetupView: View { Spacer() } - .frame(width: 600, height: 450) + .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() .background(.clear) } diff --git a/Gaze/Views/Onboarding/OnboardingContainerView.swift b/Gaze/Views/Onboarding/OnboardingContainerView.swift index a03ed72..e071d99 100644 --- a/Gaze/Views/Onboarding/OnboardingContainerView.swift +++ b/Gaze/Views/Onboarding/OnboardingContainerView.swift @@ -30,6 +30,7 @@ struct OnboardingContainerView: View { @State private var postureEnabled = true @State private var postureIntervalMinutes = 30 @State private var launchAtLogin = false + @State private var subtleReminderSizePercentage = 5.0 @State private var isAnimatingOut = false @Environment(\.dismiss) private var dismiss @@ -75,6 +76,7 @@ struct OnboardingContainerView: View { SettingsOnboardingView( launchAtLogin: $launchAtLogin, + subtleReminderSizePercentage: $subtleReminderSizePercentage, isOnboarding: true ) .tag(4) @@ -136,6 +138,7 @@ struct OnboardingContainerView: View { } } } + .frame(minWidth: 1000, minHeight: 750) .opacity(isAnimatingOut ? 0 : 1) .scaleEffect(isAnimatingOut ? 0.3 : 1.0) } @@ -159,6 +162,7 @@ struct OnboardingContainerView: View { ) settingsManager.settings.launchAtLogin = launchAtLogin + settingsManager.settings.subtleReminderSizePercentage = subtleReminderSizePercentage settingsManager.settings.hasCompletedOnboarding = true // Apply launch at login setting diff --git a/Gaze/Views/Onboarding/PostureSetupView.swift b/Gaze/Views/Onboarding/PostureSetupView.swift index b6a51a2..f769233 100644 --- a/Gaze/Views/Onboarding/PostureSetupView.swift +++ b/Gaze/Views/Onboarding/PostureSetupView.swift @@ -92,7 +92,7 @@ struct PostureSetupView: View { Spacer() } - .frame(width: 600, height: 450) + .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() .background(.clear) } diff --git a/Gaze/Views/Onboarding/SettingsOnboardingView.swift b/Gaze/Views/Onboarding/SettingsOnboardingView.swift index 44d8910..428b42f 100644 --- a/Gaze/Views/Onboarding/SettingsOnboardingView.swift +++ b/Gaze/Views/Onboarding/SettingsOnboardingView.swift @@ -9,6 +9,7 @@ import SwiftUI struct SettingsOnboardingView: View { @Binding var launchAtLogin: Bool + @Binding var subtleReminderSizePercentage: Double var isOnboarding: Bool = true var body: some View { @@ -48,6 +49,29 @@ struct SettingsOnboardingView: View { .padding() .glassEffect(.regular, in: .rect(cornerRadius: 12)) + // Subtle Reminder Size Configuration + VStack(alignment: .leading, spacing: 12) { + Text("Subtle Reminder Size") + .font(.headline) + + Text("Adjust the size of blink and posture reminders") + .font(.caption) + .foregroundColor(.secondary) + + HStack { + Slider( + value: $subtleReminderSizePercentage, + in: 2...35, + step: 1 + ) + Text("\(Int(subtleReminderSizePercentage))%") + .frame(width: 50, alignment: .trailing) + .monospacedDigit() + } + } + .padding() + .glassEffect(.regular, in: .rect(cornerRadius: 12)) + // Links Section VStack(spacing: 12) { Text("Support & Contribute") @@ -116,7 +140,7 @@ struct SettingsOnboardingView: View { Spacer() } - .frame(width: 600, height: 450) + .frame(minWidth: 650, minHeight: 650) .padding() .background(.clear) } @@ -137,6 +161,7 @@ struct SettingsOnboardingView: View { #Preview("Settings Onboarding - Launch Disabled") { SettingsOnboardingView( launchAtLogin: .constant(false), + subtleReminderSizePercentage: .constant(5.0), isOnboarding: true ) } @@ -144,6 +169,7 @@ struct SettingsOnboardingView: View { #Preview("Settings Onboarding - Launch Enabled") { SettingsOnboardingView( launchAtLogin: .constant(true), + subtleReminderSizePercentage: .constant(10.0), isOnboarding: true ) } diff --git a/Gaze/Views/Onboarding/UserTimersView.swift b/Gaze/Views/Onboarding/UserTimersView.swift new file mode 100644 index 0000000..0e2bbc4 --- /dev/null +++ b/Gaze/Views/Onboarding/UserTimersView.swift @@ -0,0 +1,295 @@ +// +// UserTimersView.swift +// Gaze +// +// Created by Mike Freno on 1/9/26. +// + +import SwiftUI + +struct UserTimersView: View { + @Binding var userTimers: [UserTimer] + @State private var editingTimer: UserTimer? + @State private var showingAddTimer = false + + var body: some View { + VStack(spacing: 30) { + Image(systemName: "clock.badge.checkmark") + .font(.system(size: 60)) + .foregroundColor(.purple) + + Text("Custom Timers") + .font(.system(size: 28, weight: .bold)) + + Text("Create your own reminder schedules") + .font(.title3) + .foregroundColor(.secondary) + + HStack(spacing: 12) { + Image(systemName: "info.circle") + .foregroundColor(.white) + Text("Add up to 3 custom timers with your own intervals and messages") + .font(.headline) + .foregroundColor(.white) + } + .padding() + .glassEffect(.regular.tint(.purple), in: .rect(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Active Timers (\(userTimers.count)/3)") + .font(.headline) + Spacer() + if userTimers.count < 3 { + Button(action: { + showingAddTimer = true + }) { + Label("Add Timer", systemImage: "plus.circle.fill") + } + .buttonStyle(.borderedProminent) + } + } + + if userTimers.isEmpty { + VStack(spacing: 12) { + Image(systemName: "clock.badge.questionmark") + .font(.system(size: 40)) + .foregroundColor(.secondary) + Text("No custom timers yet") + .font(.subheadline) + .foregroundColor(.secondary) + Text("Click 'Add Timer' to create your first custom reminder") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(40) + } else { + ScrollView { + VStack(spacing: 8) { + ForEach(userTimers) { timer in + UserTimerRow( + timer: timer, + onEdit: { + editingTimer = timer + }, + onDelete: { + if let index = userTimers.firstIndex(where: { + $0.id == timer.id + }) { + userTimers.remove(at: index) + } + } + ) + } + } + } + .frame(maxHeight: 200) + } + } + .padding() + .glassEffect(.regular, in: .rect(cornerRadius: 12)) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + .background(.clear) + .sheet(isPresented: $showingAddTimer) { + UserTimerEditSheet( + timer: nil, + onSave: { newTimer in + userTimers.append(newTimer) + showingAddTimer = false + }, + onCancel: { + showingAddTimer = false + } + ) + } + .sheet(item: $editingTimer) { timer in + UserTimerEditSheet( + timer: timer, + onSave: { updatedTimer in + if let index = userTimers.firstIndex(where: { $0.id == timer.id }) { + userTimers[index] = updatedTimer + } + editingTimer = nil + }, + onCancel: { + editingTimer = nil + } + ) + } + } +} + +struct UserTimerRow: View { + let timer: UserTimer + var onEdit: () -> Void + var onDelete: () -> Void + @State private var isHovered = false + + var body: some View { + HStack(spacing: 12) { + Image(systemName: timer.type == .subtle ? "eye.circle" : "rectangle.on.rectangle") + .foregroundColor(.purple) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 4) { + Text(timer.message ?? "Custom Timer") + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + Text("\(timer.type.displayName) • \(timer.timeOnScreenSeconds)s on screen") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + HStack(spacing: 4) { + Button(action: onEdit) { + Image(systemName: "pencil.circle") + .font(.title3) + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + + Button(action: onDelete) { + Image(systemName: "trash.circle") + .font(.title3) + .foregroundColor(.red) + } + .buttonStyle(.plain) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondary.opacity(isHovered ? 0.1 : 0.05)) + ) + .onHover { hovering in + isHovered = hovering + } + } +} + +struct UserTimerEditSheet: View { + let timer: UserTimer? + var onSave: (UserTimer) -> Void + var onCancel: () -> Void + + @State private var message: String + @State private var type: UserTimerType + @State private var timeOnScreen: Int + + init( + timer: UserTimer?, + onSave: @escaping (UserTimer) -> Void, + onCancel: @escaping () -> Void + ) { + self.timer = timer + self.onSave = onSave + self.onCancel = onCancel + + _message = State(initialValue: timer?.message ?? "") + _type = State(initialValue: timer?.type ?? .subtle) + _timeOnScreen = State(initialValue: timer?.timeOnScreenSeconds ?? 30) + } + + var body: some View { + VStack(spacing: 24) { + Text(timer == nil ? "Add Custom Timer" : "Edit Custom Timer") + .font(.title2) + .fontWeight(.bold) + + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Display Type") + .font(.headline) + + Picker("Display Type", selection: $type) { + ForEach(UserTimerType.allCases) { timerType in + Text(timerType.displayName).tag(timerType) + } + } + .pickerStyle(.segmented) + + Text( + type == .subtle + ? "Small reminder in corner of screen" + : "Full screen reminder with animation" + ) + .font(.caption) + .foregroundColor(.secondary) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Duration on Screen") + .font(.headline) + HStack { + Slider( + value: Binding( + get: { Double(timeOnScreen) }, + set: { timeOnScreen = Int($0) } + ), + in: 5...120, + step: 5 + ) + Text("\(timeOnScreen)s") + .frame(width: 50, alignment: .trailing) + .monospacedDigit() + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Message (Optional)") + .font(.headline) + TextField("Enter custom reminder message", text: $message) + .textFieldStyle(.roundedBorder) + Text("Leave blank to show a default timer notification") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .glassEffect(.regular, in: .rect(cornerRadius: 12)) + + HStack(spacing: 12) { + Button("Cancel", action: onCancel) + .keyboardShortcut(.escape) + + Button(timer == nil ? "Add" : "Save") { + let newTimer = UserTimer( + id: timer?.id ?? UUID().uuidString, + type: type, + timeOnScreenSeconds: timeOnScreen, + message: message.isEmpty ? nil : message + ) + onSave(newTimer) + } + .keyboardShortcut(.return) + .buttonStyle(.borderedProminent) + } + } + .padding(24) + .frame(width: 400) + } +} + +#Preview("User Timers - Empty") { + UserTimersView(userTimers: .constant([])) +} + +#Preview("User Timers - With Timers") { + UserTimersView( + userTimers: .constant([ + UserTimer( + id: "1", type: .subtle, timeOnScreenSeconds: 30, message: "Take a break"), + UserTimer( + id: "2", type: .overlay, timeOnScreenSeconds: 60, + message: "Stretch your legs"), + ]) + ) +} diff --git a/Gaze/Views/SettingsWindowView.swift b/Gaze/Views/SettingsWindowView.swift index 15e84ae..46d7c7a 100644 --- a/Gaze/Views/SettingsWindowView.swift +++ b/Gaze/Views/SettingsWindowView.swift @@ -18,21 +18,28 @@ struct SettingsWindowView: View { @State private var postureEnabled: Bool @State private var postureIntervalMinutes: Int @State private var launchAtLogin: Bool - + @State private var subtleReminderSizePercentage: Double + init(settingsManager: SettingsManager, initialTab: Int = 0) { self.settingsManager = settingsManager - + _currentTab = State(initialValue: initialTab) _lookAwayEnabled = State(initialValue: settingsManager.settings.lookAwayTimer.enabled) - _lookAwayIntervalMinutes = State(initialValue: settingsManager.settings.lookAwayTimer.intervalSeconds / 60) - _lookAwayCountdownSeconds = State(initialValue: settingsManager.settings.lookAwayCountdownSeconds) + _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) + _blinkIntervalMinutes = State( + initialValue: settingsManager.settings.blinkTimer.intervalSeconds / 60) _postureEnabled = State(initialValue: settingsManager.settings.postureTimer.enabled) - _postureIntervalMinutes = State(initialValue: settingsManager.settings.postureTimer.intervalSeconds / 60) + _postureIntervalMinutes = State( + initialValue: settingsManager.settings.postureTimer.intervalSeconds / 60) _launchAtLogin = State(initialValue: settingsManager.settings.launchAtLogin) + _subtleReminderSizePercentage = State( + initialValue: settingsManager.settings.subtleReminderSizePercentage) } - + var body: some View { VStack(spacing: 0) { TabView(selection: $currentTab) { @@ -45,7 +52,7 @@ struct SettingsWindowView: View { .tabItem { Label("Look Away", systemImage: "eye.fill") } - + BlinkSetupView( enabled: $blinkEnabled, intervalMinutes: $blinkIntervalMinutes @@ -54,7 +61,7 @@ struct SettingsWindowView: View { .tabItem { Label("Blink", systemImage: "eye.circle.fill") } - + PostureSetupView( enabled: $postureEnabled, intervalMinutes: $postureIntervalMinutes @@ -63,28 +70,34 @@ struct SettingsWindowView: View { .tabItem { Label("Posture", systemImage: "figure.stand") } - + + UserTimersView(userTimers: $settingsManager.settings.userTimers) + .tag(3) + .tabItem { + Label("User Timers", systemImage: "plus.circle") + } + SettingsOnboardingView( launchAtLogin: $launchAtLogin, + subtleReminderSizePercentage: $subtleReminderSizePercentage, isOnboarding: false ) - .tag(3) + .tag(4) .tabItem { Label("General", systemImage: "gearshape.fill") } } - .padding() - + Divider() - + HStack { Spacer() - + Button("Cancel") { closeWindow() } .keyboardShortcut(.escape) - + Button("Apply") { applySettings() closeWindow() @@ -94,33 +107,36 @@ struct SettingsWindowView: View { } .padding() } - .frame(width: 600, height: 550) - .onReceive(NotificationCenter.default.publisher(for: Notification.Name("SwitchToSettingsTab"))) { notification in + .frame(minWidth: 700, minHeight: 800) + .onReceive( + NotificationCenter.default.publisher(for: Notification.Name("SwitchToSettingsTab")) + ) { notification in if let tab = notification.object as? Int { currentTab = tab } } } - + 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 - + settingsManager.settings.subtleReminderSizePercentage = subtleReminderSizePercentage + do { if launchAtLogin { try LaunchAtLoginManager.enable() @@ -131,7 +147,7 @@ struct SettingsWindowView: View { print("Failed to set launch at login: \(error)") } } - + private func closeWindow() { if let window = NSApplication.shared.windows.first(where: { $0.title == "Settings" }) { window.close()