general: sanity improvements
This commit is contained in:
@@ -424,7 +424,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Gaze/Gaze.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = 6GK4F9L62V;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -438,7 +438,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 0.2.3;
|
||||
MARKETING_VERSION = 0.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -460,7 +460,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Gaze/Gaze.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = 6GK4F9L62V;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -474,7 +474,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 0.2.3;
|
||||
MARKETING_VERSION = 0.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -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
|
||||
|
||||
15
Gaze/Constants/WindowIdentifiers.swift
Normal file
15
Gaze/Constants/WindowIdentifiers.swift
Normal 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")
|
||||
}
|
||||
56
Gaze/Extensions/TimeIntervalExtensions.swift
Normal file
56
Gaze/Extensions/TimeIntervalExtensions.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
33
Gaze/Models/TimerIdentifier.swift
Normal file
33
Gaze/Models/TimerIdentifier.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user