general: sanity improvements

This commit is contained in:
Michael Freno
2026-01-12 00:40:04 -05:00
parent e1f18f8344
commit a364cad05c
13 changed files with 502 additions and 457 deletions

View File

@@ -12,36 +12,34 @@ import Combine
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
@Published var timerEngine: TimerEngine?
private var settingsManager: SettingsManager?
private let settingsManager: SettingsManager = .shared
private var updateManager: UpdateManager?
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!)
timerEngine = TimerEngine(settingsManager: settingsManager)
// Initialize update manager after onboarding is complete
if settingsManager!.settings.hasCompletedOnboarding {
if settingsManager.settings.hasCompletedOnboarding {
updateManager = UpdateManager.shared
}
// Detect App Store version asynchronously at launch
Task {
await settingsManager?.detectAppStoreVersion()
await settingsManager.detectAppStoreVersion()
}
setupLifecycleObservers()
observeSettingsChanges()
// Start timers if onboarding is complete
if settingsManager!.settings.hasCompletedOnboarding {
if settingsManager.settings.hasCompletedOnboarding {
startTimers()
}
}
@@ -63,7 +61,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
}
private func observeSettingsChanges() {
settingsManager?.$settings
settingsManager.$settings
.sink { [weak self] settings in
if settings.hasCompletedOnboarding && self?.hasStartedTimers == false {
self?.startTimers()
@@ -78,7 +76,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
}
func applicationWillTerminate(_ notification: Notification) {
settingsManager?.save()
settingsManager.save()
timerEngine?.stop()
}
@@ -99,38 +97,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
}
@objc private func systemWillSleep() {
// Save timer states
if let timerEngine = timerEngine {
for (type, state) in timerEngine.timerStates {
if state.isActive && !state.isPaused {
timerStateBeforeSleep[type] = Date()
}
}
}
timerEngine?.pause()
settingsManager?.save()
timerEngine?.handleSystemSleep()
settingsManager.save()
}
@objc private func systemDidWake() {
guard let timerEngine = timerEngine else { return }
let now = Date()
for (type, sleepTime) in timerStateBeforeSleep {
let elapsed = Int(now.timeIntervalSince(sleepTime))
if var state = timerEngine.timerStates[type] {
state.remainingSeconds = max(0, state.remainingSeconds - elapsed)
timerEngine.timerStates[type] = state
// If timer expired during sleep, trigger it now
if state.remainingSeconds <= 0 {
timerEngine.timerStates[type]?.remainingSeconds = 1
}
}
}
timerStateBeforeSleep.removeAll()
timerEngine.resume()
timerEngine?.handleSystemWake()
}
private func observeReminderEvents() {
@@ -156,14 +128,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
}
)
case .blinkTriggered:
let sizePercentage = settingsManager?.settings.subtleReminderSize.percentage ?? 5.0
let sizePercentage = settingsManager.settings.subtleReminderSize.percentage
contentView = AnyView(
BlinkReminderView(sizePercentage: sizePercentage) { [weak self] in
self?.timerEngine?.dismissReminder()
}
)
case .postureTriggered:
let sizePercentage = settingsManager?.settings.subtleReminderSize.percentage ?? 5.0
let sizePercentage = settingsManager.settings.subtleReminderSize.percentage
contentView = AnyView(
PostureReminderView(sizePercentage: sizePercentage) { [weak self] in
self?.timerEngine?.dismissReminder()
@@ -177,7 +149,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
}
)
} else {
let sizePercentage = settingsManager?.settings.subtleReminderSize.percentage ?? 5.0
let sizePercentage = settingsManager.settings.subtleReminderSize.percentage
contentView = AnyView(
UserTimerReminderView(timer: timer, sizePercentage: sizePercentage) { [weak self] in
self?.timerEngine?.dismissReminder()
@@ -199,18 +171,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
defer: false
)
window.identifier = WindowIdentifiers.reminder
window.level = .floating
window.isOpaque = false
window.backgroundColor = .clear
window.contentView = NSHostingView(rootView: content)
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
// Ensure this window can receive key events
window.acceptsMouseMovedEvents = true
window.makeFirstResponder(window.contentView)
let windowController = NSWindowController(window: window)
windowController.showWindow(nil)
// Make sure the window is brought to front and made key for key events
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
@@ -235,56 +206,38 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
// Public method to reopen onboarding window
func openOnboarding() {
// Post notification to close menu bar popover
NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil)
// Small delay to allow menu bar to close before opening onboarding
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self, let settingsManager = self.settingsManager else { return }
guard let self = self else { return }
// Check if onboarding window already exists from the WindowGroup
let existingWindow = NSApplication.shared.windows.first { window in
// Check if window contains OnboardingContainerView by examining its content view
if window.contentView is NSHostingView<OnboardingContainerView> {
return true
}
// Also check for windows with our expected size (onboarding window dimensions)
return window.frame.size.width == 700 && window.frame.size.height == 700
&& window.styleMask.contains(.titled)
&& window.title.isEmpty // WindowGroup windows have empty title by default
}
if let window = existingWindow {
// Reuse existing window - just bring it to front
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
} else {
// Create new window matching WindowGroup style
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 700, height: 700),
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
// Match the WindowGroup style: hiddenTitleBar
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.center()
window.isReleasedWhenClosed = true
window.contentView = NSHostingView(
rootView: OnboardingContainerView(settingsManager: settingsManager)
)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
if self.activateWindow(withIdentifier: WindowIdentifiers.onboarding) {
return
}
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 700, height: 700),
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.identifier = WindowIdentifiers.onboarding
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.center()
window.isReleasedWhenClosed = true
window.contentView = NSHostingView(
rootView: OnboardingContainerView(settingsManager: self.settingsManager)
)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
}
private func openSettingsWindow(tab: Int) {
// If window already exists, switch to the tab and bring it to front
if let existingWindow = settingsWindowController?.window {
if let existingWindow = findWindow(withIdentifier: WindowIdentifiers.settings) {
NotificationCenter.default.post(
name: Notification.Name("SwitchToSettingsTab"),
object: tab
@@ -301,12 +254,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
defer: false
)
window.identifier = WindowIdentifiers.settings
window.title = "Settings"
window.center()
window.setFrameAutosaveName("SettingsWindow")
window.isReleasedWhenClosed = false
window.contentView = NSHostingView(
rootView: SettingsWindowView(settingsManager: settingsManager!, initialTab: tab)
rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: tab)
)
let windowController = NSWindowController(window: window)
@@ -316,7 +270,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
NSApp.activate(ignoringOtherApps: true)
// Observe when window is closed to clean up reference
NotificationCenter.default.addObserver(
self,
selector: #selector(settingsWindowWillCloseNotification(_:)),
@@ -328,6 +281,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
@objc private func settingsWindowWillCloseNotification(_ notification: Notification) {
settingsWindowController = nil
}
/// Finds a window by its identifier
private func findWindow(withIdentifier identifier: NSUserInterfaceItemIdentifier) -> NSWindow? {
return NSApplication.shared.windows.first { $0.identifier == identifier }
}
/// Brings window to front if it exists, returns true if found
private func activateWindow(withIdentifier identifier: NSUserInterfaceItemIdentifier) -> Bool {
guard let window = findWindow(withIdentifier: identifier) else {
return false
}
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return true
}
}
// Custom window class that can become key to receive keyboard events

View File

@@ -0,0 +1,15 @@
//
// WindowIdentifiers.swift
// Gaze
//
// Created by Mike Freno on 1/11/26.
//
import AppKit
/// Centralized window identifiers for robust window management
enum WindowIdentifiers {
static let onboarding = NSUserInterfaceItemIdentifier("com.gaze.window.onboarding")
static let settings = NSUserInterfaceItemIdentifier("com.gaze.window.settings")
static let reminder = NSUserInterfaceItemIdentifier("com.gaze.window.reminder")
}

View File

@@ -0,0 +1,56 @@
//
// TimeIntervalExtensions.swift
// Gaze
//
// Created by Mike Freno on 1/11/26.
//
import Foundation
extension TimeInterval {
/// Formats time interval as timer duration string
/// Examples: "5m 30s", "1h 23m", "45s"
func formatAsTimerDuration() -> String {
let seconds = Int(self)
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)
}
}
/// Formats time interval with full precision (hours:minutes:seconds)
/// Example: "1:23:45" or "5:30"
func formatAsTimerDurationFull() -> String {
let seconds = Int(self)
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)
}
}
}
extension Int {
/// Formats integer seconds as timer duration
var asTimerDuration: String {
TimeInterval(self).formatAsTimerDuration()
}
/// Formats integer seconds with full precision
var asTimerDurationFull: String {
TimeInterval(self).formatAsTimerDurationFull()
}
}

View File

@@ -95,14 +95,18 @@ struct AppSettings: Codable, Equatable, Hashable {
)
}
/// Manual Equatable implementation required because isAppStoreVersion
/// is excluded from Codable persistence but included in equality checks
static func == (lhs: AppSettings, rhs: AppSettings) -> Bool {
lhs.lookAwayTimer == rhs.lookAwayTimer
&& lhs.lookAwayCountdownSeconds == rhs.lookAwayCountdownSeconds
&& lhs.blinkTimer == rhs.blinkTimer && lhs.postureTimer == rhs.postureTimer
&& lhs.blinkTimer == rhs.blinkTimer
&& lhs.postureTimer == rhs.postureTimer
&& lhs.userTimers == rhs.userTimers
&& lhs.subtleReminderSize == rhs.subtleReminderSize
&& lhs.hasCompletedOnboarding == rhs.hasCompletedOnboarding
&& lhs.launchAtLogin == rhs.launchAtLogin && lhs.playSounds == rhs.playSounds
&& lhs.launchAtLogin == rhs.launchAtLogin
&& lhs.playSounds == rhs.playSounds
&& lhs.isAppStoreVersion == rhs.isAppStoreVersion
}

View File

@@ -13,6 +13,19 @@ enum ReminderEvent: Equatable {
case postureTriggered
case userTimerTriggered(UserTimer)
var identifier: TimerIdentifier {
switch self {
case .lookAwayTriggered:
return .builtIn(.lookAway)
case .blinkTriggered:
return .builtIn(.blink)
case .postureTriggered:
return .builtIn(.posture)
case .userTimerTriggered(let timer):
return .user(id: timer.id)
}
}
var iconName: String {
switch self {
case .lookAwayTriggered:

View File

@@ -20,8 +20,4 @@ struct TimerConfiguration: Codable, Equatable, Hashable {
get { intervalSeconds / 60 }
set { intervalSeconds = newValue * 60 }
}
static func == (lhs: TimerConfiguration, rhs: TimerConfiguration) -> Bool {
lhs.enabled == rhs.enabled && lhs.intervalSeconds == rhs.intervalSeconds
}
}

View File

@@ -0,0 +1,33 @@
//
// TimerIdentifier.swift
// Gaze
//
// Created by Mike Freno on 1/12/26.
//
import Foundation
/// Unified identifier for both built-in and user-defined timers
enum TimerIdentifier: Hashable, Codable {
case builtIn(TimerType)
case user(id: String)
var displayName: String {
switch self {
case .builtIn(let type):
return type.displayName
case .user:
// Will be looked up from settings in views
return "User Timer"
}
}
var iconName: String {
switch self {
case .builtIn(let type):
return type.iconName
case .user:
return "clock.fill"
}
}
}

View File

@@ -8,27 +8,19 @@
import Foundation
struct TimerState: Equatable, Hashable {
let type: TimerType
let identifier: TimerIdentifier
var remainingSeconds: Int
var isPaused: Bool
var isActive: Bool
var targetDate: Date
let originalIntervalSeconds: Int // Store original interval for comparison
init(type: TimerType, intervalSeconds: Int, isPaused: Bool = false, isActive: Bool = true) {
self.type = type
init(identifier: TimerIdentifier, intervalSeconds: Int, isPaused: Bool = false, isActive: Bool = true) {
self.identifier = identifier
self.remainingSeconds = intervalSeconds
self.isPaused = isPaused
self.isActive = isActive
self.targetDate = Date().addingTimeInterval(Double(intervalSeconds))
self.originalIntervalSeconds = 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()
&& lhs.originalIntervalSeconds == rhs.originalIntervalSeconds
}
}

View File

@@ -39,12 +39,6 @@ struct UserTimer: Codable, Equatable, Identifiable, Hashable {
self.colorHex = colorHex ?? UserTimer.defaultColors[0]
self.enabled = enabled
}
static func == (lhs: UserTimer, rhs: UserTimer) -> Bool {
lhs.id == rhs.id && lhs.title == rhs.title && lhs.type == rhs.type
&& lhs.timeOnScreenSeconds == rhs.timeOnScreenSeconds && lhs.intervalMinutes == rhs.intervalMinutes
&& lhs.message == rhs.message && lhs.colorHex == rhs.colorHex && lhs.enabled == rhs.enabled
}
// Default color palette for user timers
static let defaultColors = [

View File

@@ -12,14 +12,18 @@ import Foundation
class SettingsManager: ObservableObject {
static let shared = SettingsManager()
@Published var settings: AppSettings {
didSet {
save()
}
}
@Published var settings: AppSettings
private let userDefaults = UserDefaults.standard
private let settingsKey = "gazeAppSettings"
private var saveCancellable: AnyCancellable?
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] =
[
.lookAway: \.lookAwayTimer,
.blink: \.blinkTimer,
.posture: \.postureTimer,
]
private init() {
#if DEBUG
@@ -27,6 +31,24 @@ class SettingsManager: ObservableObject {
UserDefaults.standard.removeObject(forKey: "gazeAppSettings")
#endif
self.settings = Self.loadSettings()
#if DEBUG
validateTimerConfigMappings()
#endif
setupDebouncedSave()
}
deinit {
saveCancellable?.cancel()
// Final save will be called by AppDelegate.applicationWillTerminate
}
private func setupDebouncedSave() {
saveCancellable =
$settings
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.sink { [weak self] _ in
self?.save()
}
}
private static func loadSettings() -> AppSettings {
@@ -38,6 +60,9 @@ class SettingsManager: ObservableObject {
return settings
}
/// 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() {
guard let data = try? JSONEncoder().encode(settings) else {
print("Failed to encode settings")
@@ -55,27 +80,39 @@ class SettingsManager: ObservableObject {
}
func timerConfiguration(for type: TimerType) -> TimerConfiguration {
switch type {
case .lookAway:
return settings.lookAwayTimer
case .blink:
return settings.blinkTimer
case .posture:
return settings.postureTimer
guard let keyPath = timerConfigKeyPaths[type] else {
preconditionFailure("Unknown timer type: \(type)")
}
return settings[keyPath: keyPath]
}
func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) {
switch type {
case .lookAway:
settings.lookAwayTimer = configuration
case .blink:
settings.blinkTimer = configuration
case .posture:
settings.postureTimer = configuration
guard let keyPath = timerConfigKeyPaths[type] else {
preconditionFailure("Unknown timer type: \(type)")
}
settings[keyPath: keyPath] = configuration
}
/// Returns all timer configurations as a dictionary
func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
var configs: [TimerType: TimerConfiguration] = [:]
for (type, keyPath) in timerConfigKeyPaths {
configs[type] = settings[keyPath: keyPath]
}
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)")
}
}
/// Detects and caches the App Store version status.
/// This should be called once at app launch to avoid async checks throughout the app.
func detectAppStoreVersion() async {

View File

@@ -10,19 +10,12 @@ import Foundation
@MainActor
class TimerEngine: ObservableObject {
@Published var timerStates: [TimerType: TimerState] = [:]
@Published var timerStates: [TimerIdentifier: TimerState] = [:]
@Published var activeReminder: ReminderEvent?
// Track user timer states separately
private var userTimerStates: [String: TimerState] = [:]
// Expose user timer states for read-only access
var userTimerStatesReadOnly: [String: TimerState] {
return userTimerStates
}
private var timerSubscription: AnyCancellable?
private let settingsManager: SettingsManager
private var sleepStartTime: Date?
init(settingsManager: SettingsManager) {
self.settingsManager = settingsManager
@@ -38,13 +31,15 @@ class TimerEngine: ObservableObject {
// Initial start - create all timer states
stop()
var newStates: [TimerType: TimerState] = [:]
var newStates: [TimerIdentifier: TimerState] = [:]
// Add built-in timers
for timerType in TimerType.allCases {
let config = settingsManager.timerConfiguration(for: timerType)
if config.enabled {
newStates[timerType] = TimerState(
type: timerType,
let identifier = TimerIdentifier.builtIn(timerType)
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: config.intervalSeconds,
isPaused: false,
isActive: true
@@ -52,14 +47,20 @@ class TimerEngine: ObservableObject {
}
}
// Add user timers
for userTimer in settingsManager.settings.userTimers where userTimer.enabled {
let identifier = TimerIdentifier.user(id: userTimer.id)
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: userTimer.intervalMinutes * 60,
isPaused: false,
isActive: true
)
}
// Assign the entire dictionary at once to trigger @Published
timerStates = newStates
// 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
@@ -70,30 +71,32 @@ class TimerEngine: ObservableObject {
}
private func updateConfigurations() {
var newStates: [TimerType: TimerState] = [:]
var newStates: [TimerIdentifier: TimerState] = [:]
// Update built-in timers
for timerType in TimerType.allCases {
let config = settingsManager.timerConfiguration(for: timerType)
let identifier = TimerIdentifier.builtIn(timerType)
if config.enabled {
if let existingState = timerStates[timerType] {
if let existingState = timerStates[identifier] {
// Timer exists - check if interval changed
if existingState.originalIntervalSeconds != config.intervalSeconds {
// Interval changed - reset with new interval
newStates[timerType] = TimerState(
type: timerType,
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: config.intervalSeconds,
isPaused: existingState.isPaused,
isActive: true
)
} else {
// Interval unchanged - keep existing state
newStates[timerType] = existingState
newStates[identifier] = existingState
}
} else {
// Timer was just enabled - create new state
newStates[timerType] = TimerState(
type: timerType,
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: config.intervalSeconds,
isPaused: false,
isActive: true
@@ -103,82 +106,85 @@ class TimerEngine: ObservableObject {
// If config.enabled is false and timer exists, it will be removed
}
// Update user timers
for userTimer in settingsManager.settings.userTimers {
let identifier = TimerIdentifier.user(id: userTimer.id)
let newIntervalSeconds = userTimer.intervalMinutes * 60
if userTimer.enabled {
if let existingState = timerStates[identifier] {
// Check if interval changed
if existingState.originalIntervalSeconds != newIntervalSeconds {
// Interval changed - reset with new interval
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: newIntervalSeconds,
isPaused: existingState.isPaused,
isActive: true
)
} else {
// Interval unchanged - keep existing state
newStates[identifier] = existingState
}
} else {
// New timer - create state
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: newIntervalSeconds,
isPaused: false,
isActive: true
)
}
}
// If timer is disabled, it will be removed
}
// Assign the entire dictionary at once to trigger @Published
timerStates = newStates
// Update user timers
updateUserTimers()
}
private func updateUserTimers() {
let currentTimerIds = Set(userTimerStates.keys)
let newTimerIds = Set(settingsManager.settings.userTimers.map { $0.id })
// Remove timers that no longer exist
let removedIds = currentTimerIds.subtracting(newTimerIds)
for id in removedIds {
userTimerStates.removeValue(forKey: id)
}
// Add or update timers
for userTimer in settingsManager.settings.userTimers {
if let existingState = userTimerStates[userTimer.id] {
// Check if interval changed
let newIntervalSeconds = userTimer.intervalMinutes * 60
if existingState.originalIntervalSeconds != newIntervalSeconds {
// Interval changed - reset with new interval
userTimerStates[userTimer.id] = TimerState(
type: .lookAway, // Placeholder
intervalSeconds: newIntervalSeconds,
isPaused: existingState.isPaused,
isActive: userTimer.enabled
)
} else {
// Just update enabled state if needed
var state = existingState
state.isActive = userTimer.enabled
userTimerStates[userTimer.id] = state
}
} else {
// New timer - create state
startUserTimer(userTimer)
}
}
}
func stop() {
timerSubscription?.cancel()
timerSubscription = nil
timerStates.removeAll()
userTimerStates.removeAll()
}
func pause() {
for (type, _) in timerStates {
timerStates[type]?.isPaused = true
for (id, _) in timerStates {
timerStates[id]?.isPaused = true
}
}
func resume() {
for (type, _) in timerStates {
timerStates[type]?.isPaused = false
for (id, _) in timerStates {
timerStates[id]?.isPaused = false
}
}
func pauseTimer(type: TimerType) {
timerStates[type]?.isPaused = true
func pauseTimer(identifier: TimerIdentifier) {
timerStates[identifier]?.isPaused = true
}
func resumeTimer(type: TimerType) {
timerStates[type]?.isPaused = false
func resumeTimer(identifier: TimerIdentifier) {
timerStates[identifier]?.isPaused = false
}
func skipNext(type: TimerType) {
guard let state = timerStates[type] else { return }
let config = settingsManager.timerConfiguration(for: type)
timerStates[type] = TimerState(
type: type,
intervalSeconds: config.intervalSeconds,
func skipNext(identifier: TimerIdentifier) {
guard let state = timerStates[identifier] else { return }
let intervalSeconds: Int
switch identifier {
case .builtIn(let type):
let config = settingsManager.timerConfiguration(for: type)
intervalSeconds = config.intervalSeconds
case .user(let id):
guard let userTimer = settingsManager.settings.userTimers.first(where: { $0.id == id }) else { return }
intervalSeconds = userTimer.intervalMinutes * 60
}
timerStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: intervalSeconds,
isPaused: state.isPaused,
isActive: state.isActive
)
@@ -188,173 +194,114 @@ class TimerEngine: ObservableObject {
guard let reminder = activeReminder else { return }
activeReminder = nil
// Skip to next interval based on reminder type
switch reminder {
case .lookAwayTriggered, .blinkTriggered, .postureTriggered:
// For built-in timers, we need to extract the TimerType
if case .lookAwayTriggered = reminder {
skipNext(type: .lookAway)
resume()
} else if case .blinkTriggered = reminder {
skipNext(type: .blink)
} else if case .postureTriggered = reminder {
skipNext(type: .posture)
}
case .userTimerTriggered(let timer):
// Reset the user timer
if let state = userTimerStates[timer.id] {
userTimerStates[timer.id] = TimerState(
type: .lookAway, // Placeholder
intervalSeconds: timer.intervalMinutes * 60,
isPaused: state.isPaused,
isActive: state.isActive
)
}
}
// Skip to next interval and resume the timer that was paused
let identifier = reminder.identifier
skipNext(identifier: identifier)
resumeTimer(identifier: identifier)
}
private func handleTick() {
guard activeReminder == nil else { return }
// Handle regular timers first
for (type, state) in timerStates {
// Handle all timers uniformly - only skip the timer that has an active reminder
for (identifier, state) in timerStates {
guard state.isActive && !state.isPaused else { continue }
// Skip the timer that triggered the current reminder
if let activeReminder = activeReminder, activeReminder.identifier == identifier {
continue
}
// prevent overshoot - in case user closes laptop while timer is running, we don't want to
// trigger on open,
// trigger on open
if state.targetDate < Date() - 3.0 { // slight grace
// Reset the timer when it has overshot its interval
let config = settingsManager.timerConfiguration(for: type)
timerStates[type] = TimerState(
type: type,
intervalSeconds: config.intervalSeconds,
isPaused: state.isPaused,
isActive: state.isActive
)
skipNext(identifier: identifier)
continue // Skip normal countdown logic after reset
}
timerStates[type]?.remainingSeconds -= 1
timerStates[identifier]?.remainingSeconds -= 1
if let updatedState = timerStates[type], updatedState.remainingSeconds <= 0 {
triggerReminder(for: type)
if let updatedState = timerStates[identifier], updatedState.remainingSeconds <= 0 {
triggerReminder(for: identifier)
break
}
}
}
func triggerReminder(for identifier: TimerIdentifier) {
// Pause only the timer that triggered
pauseTimer(identifier: identifier)
// 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)
switch identifier {
case .builtIn(let type):
switch type {
case .lookAway:
activeReminder = .lookAwayTriggered(
countdownSeconds: settingsManager.settings.lookAwayCountdownSeconds)
case .blink:
activeReminder = .blinkTriggered
case .posture:
activeReminder = .postureTriggered
}
}
}
private func triggerUserTimerReminder(forId id: String) {
if let userTimer = settingsManager.settings.userTimers.first(where: { $0.id == id }) {
activeReminder = .userTimerTriggered(userTimer)
}
}
func triggerReminder(for type: TimerType) {
switch type {
case .lookAway:
pause()
activeReminder = .lookAwayTriggered(
countdownSeconds: settingsManager.settings.lookAwayCountdownSeconds)
case .blink:
activeReminder = .blinkTriggered
case .posture:
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.intervalMinutes * 60,
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 toggleUserTimerPause(_ userTimerId: String) {
if let state = userTimerStates[userTimerId] {
if state.isPaused {
resumeUserTimer(userTimerId)
} else {
pauseUserTimer(userTimerId)
case .user(let id):
if let userTimer = settingsManager.settings.userTimers.first(where: { $0.id == id }) {
activeReminder = .userTimerTriggered(userTimer)
}
}
}
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 }
func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval {
guard let state = timerStates[identifier] else { return 0 }
return TimeInterval(state.remainingSeconds)
}
func getFormattedTimeRemaining(for type: TimerType) -> String {
let seconds = Int(getTimeRemaining(for: type))
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)
}
func getFormattedTimeRemaining(for identifier: TimerIdentifier) -> String {
return getTimeRemaining(for: identifier).formatAsTimerDurationFull()
}
func isUserTimerPaused(_ userTimerId: String) -> Bool {
return userTimerStates[userTimerId]?.isPaused ?? true
func isTimerPaused(_ identifier: TimerIdentifier) -> Bool {
return timerStates[identifier]?.isPaused ?? true
}
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)
/// Handles system sleep event
/// - Saves current time for elapsed calculation
/// - Pauses all active timers
func handleSystemSleep() {
sleepStartTime = Date()
pause()
}
/// Handles system wake event
/// - Calculates elapsed time during sleep
/// - Adjusts remaining time for all active timers
/// - Timers that expired during sleep will trigger immediately (1s delay)
/// - Resumes all timers
func handleSystemWake() {
guard let sleepStart = sleepStartTime else {
return
}
defer {
sleepStartTime = nil
}
let elapsedSeconds = Int(Date().timeIntervalSince(sleepStart))
guard elapsedSeconds >= 1 else {
resume()
return
}
for (identifier, state) in timerStates where state.isActive && !state.isPaused {
var updatedState = state
updatedState.remainingSeconds = max(0, state.remainingSeconds - elapsedSeconds)
if updatedState.remainingSeconds <= 0 {
updatedState.remainingSeconds = 1
}
timerStates[identifier] = updatedState
}
resume()
}
}

View File

@@ -187,53 +187,37 @@ struct MenuBarContentView: View {
.padding(.horizontal)
.padding(.top, 8)
// Show regular timers with individual pause/resume controls
ForEach(Array(timerEngine.timerStates.keys), id: \.self) { timerType in
if let state = timerEngine.timerStates[timerType] {
// Show all timers using unified identifier system
ForEach(getSortedTimerIdentifiers(timerEngine: timerEngine), id: \.self) { identifier in
if let state = timerEngine.timerStates[identifier] {
TimerStatusRowWithIndividualControls(
variant: .builtIn(timerType),
identifier: identifier,
timerEngine: timerEngine,
settingsManager: settingsManager,
onSkip: {
timerEngine.skipNext(type: timerType)
timerEngine.skipNext(identifier: identifier)
},
onDevTrigger: {
timerEngine.triggerReminder(for: timerType)
timerEngine.triggerReminder(for: identifier)
},
onTogglePause: { isPaused in
if isPaused {
timerEngine.pauseTimer(type: timerType)
timerEngine.pauseTimer(identifier: identifier)
} else {
timerEngine.resumeTimer(type: timerType)
timerEngine.resumeTimer(identifier: identifier)
}
},
onTap: {
onOpenSettingsTab(timerType.tabIndex)
switch identifier {
case .builtIn(let type):
onOpenSettingsTab(type.tabIndex)
case .user:
onOpenSettingsTab(3) // User Timers tab
}
}
)
}
}
// Show user timers with individual pause/resume controls
ForEach(settingsManager.settings.userTimers.filter { $0.enabled }, id: \.id) {
userTimer in
TimerStatusRowWithIndividualControls(
variant: .user(userTimer),
timerEngine: timerEngine,
onSkip: {
//TODO
},
onTogglePause: { isPaused in
if isPaused {
timerEngine.pauseUserTimer(userTimer.id)
} else {
timerEngine.resumeUserTimer(userTimer.id)
}
},
onTap: {
onOpenSettingsTab(3) // Switch to User Timers tab
}
)
}
}
.padding(.bottom, 8)
@@ -308,50 +292,28 @@ struct MenuBarContentView: View {
let activeStates = timerEngine.timerStates.values.filter { $0.isActive }
return !activeStates.isEmpty && activeStates.allSatisfy { $0.isPaused }
}
}
struct TimerStatusRowWithIndividualControls: View {
enum TimerVariant {
case builtIn(TimerType)
case user(UserTimer)
var displayName: String {
switch self {
case .builtIn(let type): return type.displayName
case .user(let timer): return timer.title
}
}
var iconName: String {
switch self {
case .builtIn(let type): return type.iconName
case .user: return "clock.fill"
}
}
var color: Color {
switch self {
case .builtIn(_):
return .accentColor
case .user(let timer): return timer.color
}
}
var tooltipText: String {
switch self {
case .builtIn(let type): return type.tooltipText
case .user(let timer):
let typeText = timer.type == .subtle ? "Subtle" : "Overlay"
let durationText = "\(timer.timeOnScreenSeconds)s on screen"
let statusText = timer.enabled ? "" : " (Disabled)"
return "\(typeText) timer - \(durationText)\(statusText)"
private func getSortedTimerIdentifiers(timerEngine: TimerEngine) -> [TimerIdentifier] {
return timerEngine.timerStates.keys.sorted { id1, id2 in
// Sort built-in timers before user timers
switch (id1, id2) {
case (.builtIn(let t1), .builtIn(let t2)):
return t1.tabIndex < t2.tabIndex
case (.builtIn, .user):
return true
case (.user, .builtIn):
return false
case (.user(let id1), .user(let id2)):
return id1 < id2
}
}
}
}
let variant: TimerVariant
struct TimerStatusRowWithIndividualControls: View {
let identifier: TimerIdentifier
@ObservedObject var timerEngine: TimerEngine
@ObservedObject var settingsManager: SettingsManager
var onSkip: () -> Void
var onDevTrigger: (() -> Void)? = nil
var onTogglePause: (Bool) -> Void
@@ -362,46 +324,89 @@ struct TimerStatusRowWithIndividualControls: View {
@State private var isHoveredPauseButton = false
private var state: TimerState? {
switch variant {
case .builtIn(let type):
return timerEngine.timerStates[type]
case .user(let timer):
return timerEngine.userTimerStatesReadOnly[timer.id]
}
return timerEngine.timerStates[identifier]
}
private var isPaused: Bool {
switch variant {
case .builtIn:
return state?.isPaused ?? false
case .user(let timer):
return !timer.enabled
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"
}
}
private var iconName: String {
switch identifier {
case .builtIn(let type):
return type.iconName
case .user:
return "clock.fill"
}
}
private var color: Color {
switch identifier {
case .builtIn(let type):
switch type {
case .lookAway: return .accentColor
case .blink: return .green
case .posture: return .orange
}
case .user(let id):
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 {
return "User Timer"
}
let typeText = timer.type == .subtle ? "Subtle" : "Overlay"
let durationText = "\(timer.timeOnScreenSeconds)s on screen"
let statusText = timer.enabled ? "" : " (Disabled)"
return "\(typeText) timer - \(durationText)\(statusText)"
}
}
private var userTimer: UserTimer? {
if case .user(let id) = identifier {
return settingsManager.settings.userTimers.first(where: { $0.id == id })
}
return nil
}
var body: some View {
HStack {
HStack {
// Show color indicator circle for user timers
if case .user(let timer) = variant {
if let timer = userTimer {
Circle()
.fill(isHoveredBody ? .white : timer.color)
.frame(width: 8, height: 8)
}
Image(systemName: variant.iconName)
.foregroundColor(isHoveredBody ? .white : variant.color)
Image(systemName: iconName)
.foregroundColor(isHoveredBody ? .white : color)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(variant.displayName)
Text(displayName)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(isHoveredBody ? .white : .primary)
.lineLimit(1)
if let state = state {
Text(timeRemaining(state))
Text(state.remainingSeconds.asTimerDuration)
.font(.caption)
.foregroundColor(isHoveredBody ? .white.opacity(0.8) : .secondary)
.monospacedDigit()
@@ -430,7 +435,7 @@ struct TimerStatusRowWithIndividualControls: View {
? GlassStyle.regular.tint(.yellow) : GlassStyle.regular,
in: .circle
)
.help("Trigger \(variant.displayName) reminder now (dev)")
.help("Trigger \(displayName) reminder now (dev)")
.onHover { hovering in
isHoveredDevTrigger = hovering
}
@@ -457,7 +462,7 @@ struct TimerStatusRowWithIndividualControls: View {
)
.help(
isPaused
? "Resume \(variant.displayName)" : "Pause \(variant.displayName)"
? "Resume \(displayName)" : "Pause \(displayName)"
)
.onHover { hovering in
isHoveredPauseButton = hovering
@@ -476,7 +481,7 @@ struct TimerStatusRowWithIndividualControls: View {
? GlassStyle.regular.tint(.accentColor) : GlassStyle.regular,
in: .circle
)
.help("Skip to next \(variant.displayName) reminder")
.help("Skip to next \(displayName) reminder")
.onHover { hovering in
isHoveredSkip = hovering
}
@@ -485,7 +490,7 @@ struct TimerStatusRowWithIndividualControls: View {
.padding(.vertical, 6)
.glassEffectIfAvailable(
isHoveredBody
? GlassStyle.regular.tint(variant.color)
? GlassStyle.regular.tint(.accentColor)
: GlassStyle.regular,
in: .rect(cornerRadius: 6)
)
@@ -493,24 +498,9 @@ struct TimerStatusRowWithIndividualControls: View {
.onHover { hovering in
isHoveredBody = hovering
}
.help(variant.tooltipText)
.help(tooltipText)
}
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") {