general: sanity improvements
This commit is contained in:
@@ -424,7 +424,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Gaze/Gaze.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Gaze/Gaze.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_TEAM = 6GK4F9L62V;
|
DEVELOPMENT_TEAM = 6GK4F9L62V;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -438,7 +438,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 0.2.3;
|
MARKETING_VERSION = 0.3.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Gaze/Gaze.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Gaze/Gaze.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_TEAM = 6GK4F9L62V;
|
DEVELOPMENT_TEAM = 6GK4F9L62V;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -474,7 +474,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 0.2.3;
|
MARKETING_VERSION = 0.3.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -12,36 +12,34 @@ import Combine
|
|||||||
@MainActor
|
@MainActor
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
||||||
@Published var timerEngine: TimerEngine?
|
@Published var timerEngine: TimerEngine?
|
||||||
private var settingsManager: SettingsManager?
|
private let settingsManager: SettingsManager = .shared
|
||||||
private var updateManager: UpdateManager?
|
private var updateManager: UpdateManager?
|
||||||
private var reminderWindowController: NSWindowController?
|
private var reminderWindowController: NSWindowController?
|
||||||
private var settingsWindowController: NSWindowController?
|
private var settingsWindowController: NSWindowController?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var timerStateBeforeSleep: [TimerType: Date] = [:]
|
|
||||||
private var hasStartedTimers = false
|
private var hasStartedTimers = false
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
// Set activation policy to hide dock icon
|
// Set activation policy to hide dock icon
|
||||||
NSApplication.shared.setActivationPolicy(.accessory)
|
NSApplication.shared.setActivationPolicy(.accessory)
|
||||||
|
|
||||||
settingsManager = SettingsManager.shared
|
timerEngine = TimerEngine(settingsManager: settingsManager)
|
||||||
timerEngine = TimerEngine(settingsManager: settingsManager!)
|
|
||||||
|
|
||||||
// Initialize update manager after onboarding is complete
|
// Initialize update manager after onboarding is complete
|
||||||
if settingsManager!.settings.hasCompletedOnboarding {
|
if settingsManager.settings.hasCompletedOnboarding {
|
||||||
updateManager = UpdateManager.shared
|
updateManager = UpdateManager.shared
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect App Store version asynchronously at launch
|
// Detect App Store version asynchronously at launch
|
||||||
Task {
|
Task {
|
||||||
await settingsManager?.detectAppStoreVersion()
|
await settingsManager.detectAppStoreVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
setupLifecycleObservers()
|
setupLifecycleObservers()
|
||||||
observeSettingsChanges()
|
observeSettingsChanges()
|
||||||
|
|
||||||
// Start timers if onboarding is complete
|
// Start timers if onboarding is complete
|
||||||
if settingsManager!.settings.hasCompletedOnboarding {
|
if settingsManager.settings.hasCompletedOnboarding {
|
||||||
startTimers()
|
startTimers()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,7 +61,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func observeSettingsChanges() {
|
private func observeSettingsChanges() {
|
||||||
settingsManager?.$settings
|
settingsManager.$settings
|
||||||
.sink { [weak self] settings in
|
.sink { [weak self] settings in
|
||||||
if settings.hasCompletedOnboarding && self?.hasStartedTimers == false {
|
if settings.hasCompletedOnboarding && self?.hasStartedTimers == false {
|
||||||
self?.startTimers()
|
self?.startTimers()
|
||||||
@@ -78,7 +76,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
settingsManager?.save()
|
settingsManager.save()
|
||||||
timerEngine?.stop()
|
timerEngine?.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,38 +97,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func systemWillSleep() {
|
@objc private func systemWillSleep() {
|
||||||
// Save timer states
|
timerEngine?.handleSystemSleep()
|
||||||
if let timerEngine = timerEngine {
|
settingsManager.save()
|
||||||
for (type, state) in timerEngine.timerStates {
|
|
||||||
if state.isActive && !state.isPaused {
|
|
||||||
timerStateBeforeSleep[type] = Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
timerEngine?.pause()
|
|
||||||
settingsManager?.save()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func systemDidWake() {
|
@objc private func systemDidWake() {
|
||||||
guard let timerEngine = timerEngine else { return }
|
timerEngine?.handleSystemWake()
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func observeReminderEvents() {
|
private func observeReminderEvents() {
|
||||||
@@ -156,14 +128,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
case .blinkTriggered:
|
case .blinkTriggered:
|
||||||
let sizePercentage = settingsManager?.settings.subtleReminderSize.percentage ?? 5.0
|
let sizePercentage = settingsManager.settings.subtleReminderSize.percentage
|
||||||
contentView = AnyView(
|
contentView = AnyView(
|
||||||
BlinkReminderView(sizePercentage: sizePercentage) { [weak self] in
|
BlinkReminderView(sizePercentage: sizePercentage) { [weak self] in
|
||||||
self?.timerEngine?.dismissReminder()
|
self?.timerEngine?.dismissReminder()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
case .postureTriggered:
|
case .postureTriggered:
|
||||||
let sizePercentage = settingsManager?.settings.subtleReminderSize.percentage ?? 5.0
|
let sizePercentage = settingsManager.settings.subtleReminderSize.percentage
|
||||||
contentView = AnyView(
|
contentView = AnyView(
|
||||||
PostureReminderView(sizePercentage: sizePercentage) { [weak self] in
|
PostureReminderView(sizePercentage: sizePercentage) { [weak self] in
|
||||||
self?.timerEngine?.dismissReminder()
|
self?.timerEngine?.dismissReminder()
|
||||||
@@ -177,7 +149,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
let sizePercentage = settingsManager?.settings.subtleReminderSize.percentage ?? 5.0
|
let sizePercentage = settingsManager.settings.subtleReminderSize.percentage
|
||||||
contentView = AnyView(
|
contentView = AnyView(
|
||||||
UserTimerReminderView(timer: timer, sizePercentage: sizePercentage) { [weak self] in
|
UserTimerReminderView(timer: timer, sizePercentage: sizePercentage) { [weak self] in
|
||||||
self?.timerEngine?.dismissReminder()
|
self?.timerEngine?.dismissReminder()
|
||||||
@@ -199,18 +171,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
defer: false
|
defer: false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
window.identifier = WindowIdentifiers.reminder
|
||||||
window.level = .floating
|
window.level = .floating
|
||||||
window.isOpaque = false
|
window.isOpaque = false
|
||||||
window.backgroundColor = .clear
|
window.backgroundColor = .clear
|
||||||
window.contentView = NSHostingView(rootView: content)
|
window.contentView = NSHostingView(rootView: content)
|
||||||
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||||
// Ensure this window can receive key events
|
|
||||||
window.acceptsMouseMovedEvents = true
|
window.acceptsMouseMovedEvents = true
|
||||||
window.makeFirstResponder(window.contentView)
|
window.makeFirstResponder(window.contentView)
|
||||||
|
|
||||||
let windowController = NSWindowController(window: window)
|
let windowController = NSWindowController(window: window)
|
||||||
windowController.showWindow(nil)
|
windowController.showWindow(nil)
|
||||||
// Make sure the window is brought to front and made key for key events
|
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
@@ -235,31 +206,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
|
|
||||||
// Public method to reopen onboarding window
|
// Public method to reopen onboarding window
|
||||||
func openOnboarding() {
|
func openOnboarding() {
|
||||||
// Post notification to close menu bar popover
|
|
||||||
NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil)
|
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
|
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
|
if self.activateWindow(withIdentifier: WindowIdentifiers.onboarding) {
|
||||||
let existingWindow = NSApplication.shared.windows.first { window in
|
return
|
||||||
// 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(
|
let window = NSWindow(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 700, height: 700),
|
contentRect: NSRect(x: 0, y: 0, width: 700, height: 700),
|
||||||
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
|
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
|
||||||
@@ -267,24 +222,22 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
defer: false
|
defer: false
|
||||||
)
|
)
|
||||||
|
|
||||||
// Match the WindowGroup style: hiddenTitleBar
|
window.identifier = WindowIdentifiers.onboarding
|
||||||
window.titleVisibility = .hidden
|
window.titleVisibility = .hidden
|
||||||
window.titlebarAppearsTransparent = true
|
window.titlebarAppearsTransparent = true
|
||||||
window.center()
|
window.center()
|
||||||
window.isReleasedWhenClosed = true
|
window.isReleasedWhenClosed = true
|
||||||
window.contentView = NSHostingView(
|
window.contentView = NSHostingView(
|
||||||
rootView: OnboardingContainerView(settingsManager: settingsManager)
|
rootView: OnboardingContainerView(settingsManager: self.settingsManager)
|
||||||
)
|
)
|
||||||
|
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func openSettingsWindow(tab: Int) {
|
private func openSettingsWindow(tab: Int) {
|
||||||
// If window already exists, switch to the tab and bring it to front
|
if let existingWindow = findWindow(withIdentifier: WindowIdentifiers.settings) {
|
||||||
if let existingWindow = settingsWindowController?.window {
|
|
||||||
NotificationCenter.default.post(
|
NotificationCenter.default.post(
|
||||||
name: Notification.Name("SwitchToSettingsTab"),
|
name: Notification.Name("SwitchToSettingsTab"),
|
||||||
object: tab
|
object: tab
|
||||||
@@ -301,12 +254,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
defer: false
|
defer: false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
window.identifier = WindowIdentifiers.settings
|
||||||
window.title = "Settings"
|
window.title = "Settings"
|
||||||
window.center()
|
window.center()
|
||||||
window.setFrameAutosaveName("SettingsWindow")
|
window.setFrameAutosaveName("SettingsWindow")
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
window.contentView = NSHostingView(
|
window.contentView = NSHostingView(
|
||||||
rootView: SettingsWindowView(settingsManager: settingsManager!, initialTab: tab)
|
rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: tab)
|
||||||
)
|
)
|
||||||
|
|
||||||
let windowController = NSWindowController(window: window)
|
let windowController = NSWindowController(window: window)
|
||||||
@@ -316,7 +270,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
|
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
// Observe when window is closed to clean up reference
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(settingsWindowWillCloseNotification(_:)),
|
selector: #selector(settingsWindowWillCloseNotification(_:)),
|
||||||
@@ -328,6 +281,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
@objc private func settingsWindowWillCloseNotification(_ notification: Notification) {
|
@objc private func settingsWindowWillCloseNotification(_ notification: Notification) {
|
||||||
settingsWindowController = nil
|
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
|
// 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 {
|
static func == (lhs: AppSettings, rhs: AppSettings) -> Bool {
|
||||||
lhs.lookAwayTimer == rhs.lookAwayTimer
|
lhs.lookAwayTimer == rhs.lookAwayTimer
|
||||||
&& lhs.lookAwayCountdownSeconds == rhs.lookAwayCountdownSeconds
|
&& 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.userTimers == rhs.userTimers
|
||||||
&& lhs.subtleReminderSize == rhs.subtleReminderSize
|
&& lhs.subtleReminderSize == rhs.subtleReminderSize
|
||||||
&& lhs.hasCompletedOnboarding == rhs.hasCompletedOnboarding
|
&& lhs.hasCompletedOnboarding == rhs.hasCompletedOnboarding
|
||||||
&& lhs.launchAtLogin == rhs.launchAtLogin && lhs.playSounds == rhs.playSounds
|
&& lhs.launchAtLogin == rhs.launchAtLogin
|
||||||
|
&& lhs.playSounds == rhs.playSounds
|
||||||
&& lhs.isAppStoreVersion == rhs.isAppStoreVersion
|
&& lhs.isAppStoreVersion == rhs.isAppStoreVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,19 @@ enum ReminderEvent: Equatable {
|
|||||||
case postureTriggered
|
case postureTriggered
|
||||||
case userTimerTriggered(UserTimer)
|
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 {
|
var iconName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .lookAwayTriggered:
|
case .lookAwayTriggered:
|
||||||
|
|||||||
@@ -20,8 +20,4 @@ struct TimerConfiguration: Codable, Equatable, Hashable {
|
|||||||
get { intervalSeconds / 60 }
|
get { intervalSeconds / 60 }
|
||||||
set { intervalSeconds = newValue * 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
|
import Foundation
|
||||||
|
|
||||||
struct TimerState: Equatable, Hashable {
|
struct TimerState: Equatable, Hashable {
|
||||||
let type: TimerType
|
let identifier: TimerIdentifier
|
||||||
var remainingSeconds: Int
|
var remainingSeconds: Int
|
||||||
var isPaused: Bool
|
var isPaused: Bool
|
||||||
var isActive: Bool
|
var isActive: Bool
|
||||||
var targetDate: Date
|
var targetDate: Date
|
||||||
let originalIntervalSeconds: Int // Store original interval for comparison
|
let originalIntervalSeconds: Int // Store original interval for comparison
|
||||||
|
|
||||||
init(type: TimerType, intervalSeconds: Int, isPaused: Bool = false, isActive: Bool = true) {
|
init(identifier: TimerIdentifier, intervalSeconds: Int, isPaused: Bool = false, isActive: Bool = true) {
|
||||||
self.type = type
|
self.identifier = identifier
|
||||||
self.remainingSeconds = intervalSeconds
|
self.remainingSeconds = intervalSeconds
|
||||||
self.isPaused = isPaused
|
self.isPaused = isPaused
|
||||||
self.isActive = isActive
|
self.isActive = isActive
|
||||||
self.targetDate = Date().addingTimeInterval(Double(intervalSeconds))
|
self.targetDate = Date().addingTimeInterval(Double(intervalSeconds))
|
||||||
self.originalIntervalSeconds = 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,12 +40,6 @@ struct UserTimer: Codable, Equatable, Identifiable, Hashable {
|
|||||||
self.enabled = enabled
|
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
|
// Default color palette for user timers
|
||||||
static let defaultColors = [
|
static let defaultColors = [
|
||||||
"9B59B6", // Purple
|
"9B59B6", // Purple
|
||||||
|
|||||||
@@ -12,14 +12,18 @@ import Foundation
|
|||||||
class SettingsManager: ObservableObject {
|
class SettingsManager: ObservableObject {
|
||||||
static let shared = SettingsManager()
|
static let shared = SettingsManager()
|
||||||
|
|
||||||
@Published var settings: AppSettings {
|
@Published var settings: AppSettings
|
||||||
didSet {
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
private let settingsKey = "gazeAppSettings"
|
private let settingsKey = "gazeAppSettings"
|
||||||
|
private var saveCancellable: AnyCancellable?
|
||||||
|
|
||||||
|
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] =
|
||||||
|
[
|
||||||
|
.lookAway: \.lookAwayTimer,
|
||||||
|
.blink: \.blinkTimer,
|
||||||
|
.posture: \.postureTimer,
|
||||||
|
]
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -27,6 +31,24 @@ class SettingsManager: ObservableObject {
|
|||||||
UserDefaults.standard.removeObject(forKey: "gazeAppSettings")
|
UserDefaults.standard.removeObject(forKey: "gazeAppSettings")
|
||||||
#endif
|
#endif
|
||||||
self.settings = Self.loadSettings()
|
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 {
|
private static func loadSettings() -> AppSettings {
|
||||||
@@ -38,6 +60,9 @@ class SettingsManager: ObservableObject {
|
|||||||
return settings
|
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() {
|
func save() {
|
||||||
guard let data = try? JSONEncoder().encode(settings) else {
|
guard let data = try? JSONEncoder().encode(settings) else {
|
||||||
print("Failed to encode settings")
|
print("Failed to encode settings")
|
||||||
@@ -55,24 +80,36 @@ class SettingsManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func timerConfiguration(for type: TimerType) -> TimerConfiguration {
|
func timerConfiguration(for type: TimerType) -> TimerConfiguration {
|
||||||
switch type {
|
guard let keyPath = timerConfigKeyPaths[type] else {
|
||||||
case .lookAway:
|
preconditionFailure("Unknown timer type: \(type)")
|
||||||
return settings.lookAwayTimer
|
|
||||||
case .blink:
|
|
||||||
return settings.blinkTimer
|
|
||||||
case .posture:
|
|
||||||
return settings.postureTimer
|
|
||||||
}
|
}
|
||||||
|
return settings[keyPath: keyPath]
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) {
|
func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) {
|
||||||
switch type {
|
guard let keyPath = timerConfigKeyPaths[type] else {
|
||||||
case .lookAway:
|
preconditionFailure("Unknown timer type: \(type)")
|
||||||
settings.lookAwayTimer = configuration
|
}
|
||||||
case .blink:
|
settings[keyPath: keyPath] = configuration
|
||||||
settings.blinkTimer = configuration
|
}
|
||||||
case .posture:
|
|
||||||
settings.postureTimer = 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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,19 +10,12 @@ import Foundation
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class TimerEngine: ObservableObject {
|
class TimerEngine: ObservableObject {
|
||||||
@Published var timerStates: [TimerType: TimerState] = [:]
|
@Published var timerStates: [TimerIdentifier: TimerState] = [:]
|
||||||
@Published var activeReminder: ReminderEvent?
|
@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 var timerSubscription: AnyCancellable?
|
||||||
private let settingsManager: SettingsManager
|
private let settingsManager: SettingsManager
|
||||||
|
private var sleepStartTime: Date?
|
||||||
|
|
||||||
init(settingsManager: SettingsManager) {
|
init(settingsManager: SettingsManager) {
|
||||||
self.settingsManager = settingsManager
|
self.settingsManager = settingsManager
|
||||||
@@ -38,13 +31,15 @@ class TimerEngine: ObservableObject {
|
|||||||
// Initial start - create all timer states
|
// Initial start - create all timer states
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
var newStates: [TimerType: TimerState] = [:]
|
var newStates: [TimerIdentifier: TimerState] = [:]
|
||||||
|
|
||||||
|
// Add built-in timers
|
||||||
for timerType in TimerType.allCases {
|
for timerType in TimerType.allCases {
|
||||||
let config = settingsManager.timerConfiguration(for: timerType)
|
let config = settingsManager.timerConfiguration(for: timerType)
|
||||||
if config.enabled {
|
if config.enabled {
|
||||||
newStates[timerType] = TimerState(
|
let identifier = TimerIdentifier.builtIn(timerType)
|
||||||
type: timerType,
|
newStates[identifier] = TimerState(
|
||||||
|
identifier: identifier,
|
||||||
intervalSeconds: config.intervalSeconds,
|
intervalSeconds: config.intervalSeconds,
|
||||||
isPaused: false,
|
isPaused: false,
|
||||||
isActive: true
|
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
|
// Assign the entire dictionary at once to trigger @Published
|
||||||
timerStates = newStates
|
timerStates = newStates
|
||||||
|
|
||||||
// Start user timers
|
|
||||||
for userTimer in settingsManager.settings.userTimers {
|
|
||||||
startUserTimer(userTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
timerSubscription = Timer.publish(every: 1.0, on: .main, in: .common)
|
timerSubscription = Timer.publish(every: 1.0, on: .main, in: .common)
|
||||||
.autoconnect()
|
.autoconnect()
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
@@ -70,30 +71,32 @@ class TimerEngine: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateConfigurations() {
|
private func updateConfigurations() {
|
||||||
var newStates: [TimerType: TimerState] = [:]
|
var newStates: [TimerIdentifier: TimerState] = [:]
|
||||||
|
|
||||||
|
// Update built-in timers
|
||||||
for timerType in TimerType.allCases {
|
for timerType in TimerType.allCases {
|
||||||
let config = settingsManager.timerConfiguration(for: timerType)
|
let config = settingsManager.timerConfiguration(for: timerType)
|
||||||
|
let identifier = TimerIdentifier.builtIn(timerType)
|
||||||
|
|
||||||
if config.enabled {
|
if config.enabled {
|
||||||
if let existingState = timerStates[timerType] {
|
if let existingState = timerStates[identifier] {
|
||||||
// Timer exists - check if interval changed
|
// Timer exists - check if interval changed
|
||||||
if existingState.originalIntervalSeconds != config.intervalSeconds {
|
if existingState.originalIntervalSeconds != config.intervalSeconds {
|
||||||
// Interval changed - reset with new interval
|
// Interval changed - reset with new interval
|
||||||
newStates[timerType] = TimerState(
|
newStates[identifier] = TimerState(
|
||||||
type: timerType,
|
identifier: identifier,
|
||||||
intervalSeconds: config.intervalSeconds,
|
intervalSeconds: config.intervalSeconds,
|
||||||
isPaused: existingState.isPaused,
|
isPaused: existingState.isPaused,
|
||||||
isActive: true
|
isActive: true
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Interval unchanged - keep existing state
|
// Interval unchanged - keep existing state
|
||||||
newStates[timerType] = existingState
|
newStates[identifier] = existingState
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Timer was just enabled - create new state
|
// Timer was just enabled - create new state
|
||||||
newStates[timerType] = TimerState(
|
newStates[identifier] = TimerState(
|
||||||
type: timerType,
|
identifier: identifier,
|
||||||
intervalSeconds: config.intervalSeconds,
|
intervalSeconds: config.intervalSeconds,
|
||||||
isPaused: false,
|
isPaused: false,
|
||||||
isActive: true
|
isActive: true
|
||||||
@@ -103,82 +106,85 @@ class TimerEngine: ObservableObject {
|
|||||||
// If config.enabled is false and timer exists, it will be removed
|
// If config.enabled is false and timer exists, it will be removed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign the entire dictionary at once to trigger @Published
|
|
||||||
timerStates = newStates
|
|
||||||
|
|
||||||
// Update user timers
|
// 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 {
|
for userTimer in settingsManager.settings.userTimers {
|
||||||
if let existingState = userTimerStates[userTimer.id] {
|
let identifier = TimerIdentifier.user(id: userTimer.id)
|
||||||
// Check if interval changed
|
|
||||||
let newIntervalSeconds = userTimer.intervalMinutes * 60
|
let newIntervalSeconds = userTimer.intervalMinutes * 60
|
||||||
|
|
||||||
|
if userTimer.enabled {
|
||||||
|
if let existingState = timerStates[identifier] {
|
||||||
|
// Check if interval changed
|
||||||
if existingState.originalIntervalSeconds != newIntervalSeconds {
|
if existingState.originalIntervalSeconds != newIntervalSeconds {
|
||||||
// Interval changed - reset with new interval
|
// Interval changed - reset with new interval
|
||||||
userTimerStates[userTimer.id] = TimerState(
|
newStates[identifier] = TimerState(
|
||||||
type: .lookAway, // Placeholder
|
identifier: identifier,
|
||||||
intervalSeconds: newIntervalSeconds,
|
intervalSeconds: newIntervalSeconds,
|
||||||
isPaused: existingState.isPaused,
|
isPaused: existingState.isPaused,
|
||||||
isActive: userTimer.enabled
|
isActive: true
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Just update enabled state if needed
|
// Interval unchanged - keep existing state
|
||||||
var state = existingState
|
newStates[identifier] = existingState
|
||||||
state.isActive = userTimer.enabled
|
|
||||||
userTimerStates[userTimer.id] = state
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// New timer - create state
|
// New timer - create state
|
||||||
startUserTimer(userTimer)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
timerSubscription?.cancel()
|
timerSubscription?.cancel()
|
||||||
timerSubscription = nil
|
timerSubscription = nil
|
||||||
timerStates.removeAll()
|
timerStates.removeAll()
|
||||||
userTimerStates.removeAll()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pause() {
|
func pause() {
|
||||||
for (type, _) in timerStates {
|
for (id, _) in timerStates {
|
||||||
timerStates[type]?.isPaused = true
|
timerStates[id]?.isPaused = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resume() {
|
func resume() {
|
||||||
for (type, _) in timerStates {
|
for (id, _) in timerStates {
|
||||||
timerStates[type]?.isPaused = false
|
timerStates[id]?.isPaused = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pauseTimer(type: TimerType) {
|
func pauseTimer(identifier: TimerIdentifier) {
|
||||||
timerStates[type]?.isPaused = true
|
timerStates[identifier]?.isPaused = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func resumeTimer(type: TimerType) {
|
func resumeTimer(identifier: TimerIdentifier) {
|
||||||
timerStates[type]?.isPaused = false
|
timerStates[identifier]?.isPaused = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func skipNext(type: TimerType) {
|
func skipNext(identifier: TimerIdentifier) {
|
||||||
guard let state = timerStates[type] else { return }
|
guard let state = timerStates[identifier] else { return }
|
||||||
|
|
||||||
|
let intervalSeconds: Int
|
||||||
|
switch identifier {
|
||||||
|
case .builtIn(let type):
|
||||||
let config = settingsManager.timerConfiguration(for: type)
|
let config = settingsManager.timerConfiguration(for: type)
|
||||||
timerStates[type] = TimerState(
|
intervalSeconds = config.intervalSeconds
|
||||||
type: type,
|
case .user(let id):
|
||||||
intervalSeconds: config.intervalSeconds,
|
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,
|
isPaused: state.isPaused,
|
||||||
isActive: state.isActive
|
isActive: state.isActive
|
||||||
)
|
)
|
||||||
@@ -188,87 +194,47 @@ class TimerEngine: ObservableObject {
|
|||||||
guard let reminder = activeReminder else { return }
|
guard let reminder = activeReminder else { return }
|
||||||
activeReminder = nil
|
activeReminder = nil
|
||||||
|
|
||||||
// Skip to next interval based on reminder type
|
// Skip to next interval and resume the timer that was paused
|
||||||
switch reminder {
|
let identifier = reminder.identifier
|
||||||
case .lookAwayTriggered, .blinkTriggered, .postureTriggered:
|
skipNext(identifier: identifier)
|
||||||
// For built-in timers, we need to extract the TimerType
|
resumeTimer(identifier: identifier)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleTick() {
|
private func handleTick() {
|
||||||
guard activeReminder == nil else { return }
|
// Handle all timers uniformly - only skip the timer that has an active reminder
|
||||||
|
for (identifier, state) in timerStates {
|
||||||
// Handle regular timers first
|
|
||||||
for (type, state) in timerStates {
|
|
||||||
guard state.isActive && !state.isPaused else { continue }
|
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
|
// 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
|
if state.targetDate < Date() - 3.0 { // slight grace
|
||||||
// Reset the timer when it has overshot its interval
|
// Reset the timer when it has overshot its interval
|
||||||
let config = settingsManager.timerConfiguration(for: type)
|
skipNext(identifier: identifier)
|
||||||
timerStates[type] = TimerState(
|
|
||||||
type: type,
|
|
||||||
intervalSeconds: config.intervalSeconds,
|
|
||||||
isPaused: state.isPaused,
|
|
||||||
isActive: state.isActive
|
|
||||||
)
|
|
||||||
continue // Skip normal countdown logic after reset
|
continue // Skip normal countdown logic after reset
|
||||||
}
|
}
|
||||||
|
|
||||||
timerStates[type]?.remainingSeconds -= 1
|
timerStates[identifier]?.remainingSeconds -= 1
|
||||||
|
|
||||||
if let updatedState = timerStates[type], updatedState.remainingSeconds <= 0 {
|
if let updatedState = timerStates[identifier], updatedState.remainingSeconds <= 0 {
|
||||||
triggerReminder(for: type)
|
triggerReminder(for: identifier)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle user timers
|
|
||||||
handleUserTimerTicks()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleUserTimerTicks() {
|
func triggerReminder(for identifier: TimerIdentifier) {
|
||||||
for (id, state) in userTimerStates {
|
// Pause only the timer that triggered
|
||||||
if !state.isActive || state.isPaused { continue }
|
pauseTimer(identifier: identifier)
|
||||||
|
|
||||||
// Update user timer countdown
|
switch identifier {
|
||||||
userTimerStates[id]?.remainingSeconds -= 1
|
case .builtIn(let type):
|
||||||
|
|
||||||
if let updatedState = userTimerStates[id], updatedState.remainingSeconds <= 0 {
|
|
||||||
// Trigger the user timer reminder
|
|
||||||
triggerUserTimerReminder(forId: id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
switch type {
|
||||||
case .lookAway:
|
case .lookAway:
|
||||||
pause()
|
|
||||||
activeReminder = .lookAwayTriggered(
|
activeReminder = .lookAwayTriggered(
|
||||||
countdownSeconds: settingsManager.settings.lookAwayCountdownSeconds)
|
countdownSeconds: settingsManager.settings.lookAwayCountdownSeconds)
|
||||||
case .blink:
|
case .blink:
|
||||||
@@ -276,85 +242,66 @@ class TimerEngine: ObservableObject {
|
|||||||
case .posture:
|
case .posture:
|
||||||
activeReminder = .postureTriggered
|
activeReminder = .postureTriggered
|
||||||
}
|
}
|
||||||
}
|
case .user(let id):
|
||||||
|
if let userTimer = settingsManager.settings.userTimers.first(where: { $0.id == id }) {
|
||||||
// User timer management methods
|
activeReminder = .userTimerTriggered(userTimer)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTimeRemaining(for type: TimerType) -> TimeInterval {
|
func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval {
|
||||||
guard let state = timerStates[type] else { return 0 }
|
guard let state = timerStates[identifier] else { return 0 }
|
||||||
return TimeInterval(state.remainingSeconds)
|
return TimeInterval(state.remainingSeconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUserTimeRemaining(for userId: String) -> TimeInterval {
|
func getFormattedTimeRemaining(for identifier: TimerIdentifier) -> String {
|
||||||
guard let state = userTimerStates[userId] else { return 0 }
|
return getTimeRemaining(for: identifier).formatAsTimerDurationFull()
|
||||||
return TimeInterval(state.remainingSeconds)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getFormattedTimeRemaining(for type: TimerType) -> String {
|
func isTimerPaused(_ identifier: TimerIdentifier) -> Bool {
|
||||||
let seconds = Int(getTimeRemaining(for: type))
|
return timerStates[identifier]?.isPaused ?? true
|
||||||
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 isUserTimerPaused(_ userTimerId: String) -> Bool {
|
/// Handles system sleep event
|
||||||
return userTimerStates[userTimerId]?.isPaused ?? true
|
/// - Saves current time for elapsed calculation
|
||||||
|
/// - Pauses all active timers
|
||||||
|
func handleSystemSleep() {
|
||||||
|
sleepStartTime = Date()
|
||||||
|
pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUserFormattedTimeRemaining(for userId: String) -> String {
|
/// Handles system wake event
|
||||||
let seconds = Int(getUserTimeRemaining(for: userId))
|
/// - Calculates elapsed time during sleep
|
||||||
let minutes = seconds / 60
|
/// - Adjusts remaining time for all active timers
|
||||||
let remainingSeconds = seconds % 60
|
/// - Timers that expired during sleep will trigger immediately (1s delay)
|
||||||
|
/// - Resumes all timers
|
||||||
|
func handleSystemWake() {
|
||||||
|
guard let sleepStart = sleepStartTime else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if minutes >= 60 {
|
defer {
|
||||||
let hours = minutes / 60
|
sleepStartTime = nil
|
||||||
let remainingMinutes = minutes % 60
|
}
|
||||||
return String(format: "%d:%02d:%02d", hours, remainingMinutes, remainingSeconds)
|
|
||||||
} else {
|
let elapsedSeconds = Int(Date().timeIntervalSince(sleepStart))
|
||||||
return String(format: "%d:%02d", minutes, remainingSeconds)
|
|
||||||
}
|
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(.horizontal)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
|
||||||
// Show regular timers with individual pause/resume controls
|
// Show all timers using unified identifier system
|
||||||
ForEach(Array(timerEngine.timerStates.keys), id: \.self) { timerType in
|
ForEach(getSortedTimerIdentifiers(timerEngine: timerEngine), id: \.self) { identifier in
|
||||||
if let state = timerEngine.timerStates[timerType] {
|
if let state = timerEngine.timerStates[identifier] {
|
||||||
TimerStatusRowWithIndividualControls(
|
TimerStatusRowWithIndividualControls(
|
||||||
variant: .builtIn(timerType),
|
identifier: identifier,
|
||||||
timerEngine: timerEngine,
|
timerEngine: timerEngine,
|
||||||
|
settingsManager: settingsManager,
|
||||||
onSkip: {
|
onSkip: {
|
||||||
timerEngine.skipNext(type: timerType)
|
timerEngine.skipNext(identifier: identifier)
|
||||||
},
|
},
|
||||||
onDevTrigger: {
|
onDevTrigger: {
|
||||||
timerEngine.triggerReminder(for: timerType)
|
timerEngine.triggerReminder(for: identifier)
|
||||||
},
|
},
|
||||||
onTogglePause: { isPaused in
|
onTogglePause: { isPaused in
|
||||||
if isPaused {
|
if isPaused {
|
||||||
timerEngine.pauseTimer(type: timerType)
|
timerEngine.pauseTimer(identifier: identifier)
|
||||||
} else {
|
} else {
|
||||||
timerEngine.resumeTimer(type: timerType)
|
timerEngine.resumeTimer(identifier: identifier)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTap: {
|
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)
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
@@ -308,50 +292,28 @@ struct MenuBarContentView: View {
|
|||||||
let activeStates = timerEngine.timerStates.values.filter { $0.isActive }
|
let activeStates = timerEngine.timerStates.values.filter { $0.isActive }
|
||||||
return !activeStates.isEmpty && activeStates.allSatisfy { $0.isPaused }
|
return !activeStates.isEmpty && activeStates.allSatisfy { $0.isPaused }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
struct TimerStatusRowWithIndividualControls: View {
|
private func getSortedTimerIdentifiers(timerEngine: TimerEngine) -> [TimerIdentifier] {
|
||||||
enum TimerVariant {
|
return timerEngine.timerStates.keys.sorted { id1, id2 in
|
||||||
case builtIn(TimerType)
|
// Sort built-in timers before user timers
|
||||||
case user(UserTimer)
|
switch (id1, id2) {
|
||||||
|
case (.builtIn(let t1), .builtIn(let t2)):
|
||||||
var displayName: String {
|
return t1.tabIndex < t2.tabIndex
|
||||||
switch self {
|
case (.builtIn, .user):
|
||||||
case .builtIn(let type): return type.displayName
|
return true
|
||||||
case .user(let timer): return timer.title
|
case (.user, .builtIn):
|
||||||
|
return false
|
||||||
|
case (.user(let id1), .user(let id2)):
|
||||||
|
return id1 < id2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let variant: TimerVariant
|
struct TimerStatusRowWithIndividualControls: View {
|
||||||
|
let identifier: TimerIdentifier
|
||||||
@ObservedObject var timerEngine: TimerEngine
|
@ObservedObject var timerEngine: TimerEngine
|
||||||
|
@ObservedObject var settingsManager: SettingsManager
|
||||||
var onSkip: () -> Void
|
var onSkip: () -> Void
|
||||||
var onDevTrigger: (() -> Void)? = nil
|
var onDevTrigger: (() -> Void)? = nil
|
||||||
var onTogglePause: (Bool) -> Void
|
var onTogglePause: (Bool) -> Void
|
||||||
@@ -362,46 +324,89 @@ struct TimerStatusRowWithIndividualControls: View {
|
|||||||
@State private var isHoveredPauseButton = false
|
@State private var isHoveredPauseButton = false
|
||||||
|
|
||||||
private var state: TimerState? {
|
private var state: TimerState? {
|
||||||
switch variant {
|
return timerEngine.timerStates[identifier]
|
||||||
case .builtIn(let type):
|
|
||||||
return timerEngine.timerStates[type]
|
|
||||||
case .user(let timer):
|
|
||||||
return timerEngine.userTimerStatesReadOnly[timer.id]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isPaused: Bool {
|
private var isPaused: Bool {
|
||||||
switch variant {
|
|
||||||
case .builtIn:
|
|
||||||
return state?.isPaused ?? false
|
return state?.isPaused ?? false
|
||||||
case .user(let timer):
|
|
||||||
return !timer.enabled
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
HStack {
|
HStack {
|
||||||
// Show color indicator circle for user timers
|
// Show color indicator circle for user timers
|
||||||
if case .user(let timer) = variant {
|
if let timer = userTimer {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(isHoveredBody ? .white : timer.color)
|
.fill(isHoveredBody ? .white : timer.color)
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
Image(systemName: variant.iconName)
|
Image(systemName: iconName)
|
||||||
.foregroundColor(isHoveredBody ? .white : variant.color)
|
.foregroundColor(isHoveredBody ? .white : color)
|
||||||
.frame(width: 20)
|
.frame(width: 20)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(variant.displayName)
|
Text(displayName)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(isHoveredBody ? .white : .primary)
|
.foregroundColor(isHoveredBody ? .white : .primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
if let state = state {
|
if let state = state {
|
||||||
Text(timeRemaining(state))
|
Text(state.remainingSeconds.asTimerDuration)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(isHoveredBody ? .white.opacity(0.8) : .secondary)
|
.foregroundColor(isHoveredBody ? .white.opacity(0.8) : .secondary)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
@@ -430,7 +435,7 @@ struct TimerStatusRowWithIndividualControls: View {
|
|||||||
? GlassStyle.regular.tint(.yellow) : GlassStyle.regular,
|
? GlassStyle.regular.tint(.yellow) : GlassStyle.regular,
|
||||||
in: .circle
|
in: .circle
|
||||||
)
|
)
|
||||||
.help("Trigger \(variant.displayName) reminder now (dev)")
|
.help("Trigger \(displayName) reminder now (dev)")
|
||||||
.onHover { hovering in
|
.onHover { hovering in
|
||||||
isHoveredDevTrigger = hovering
|
isHoveredDevTrigger = hovering
|
||||||
}
|
}
|
||||||
@@ -457,7 +462,7 @@ struct TimerStatusRowWithIndividualControls: View {
|
|||||||
)
|
)
|
||||||
.help(
|
.help(
|
||||||
isPaused
|
isPaused
|
||||||
? "Resume \(variant.displayName)" : "Pause \(variant.displayName)"
|
? "Resume \(displayName)" : "Pause \(displayName)"
|
||||||
)
|
)
|
||||||
.onHover { hovering in
|
.onHover { hovering in
|
||||||
isHoveredPauseButton = hovering
|
isHoveredPauseButton = hovering
|
||||||
@@ -476,7 +481,7 @@ struct TimerStatusRowWithIndividualControls: View {
|
|||||||
? GlassStyle.regular.tint(.accentColor) : GlassStyle.regular,
|
? GlassStyle.regular.tint(.accentColor) : GlassStyle.regular,
|
||||||
in: .circle
|
in: .circle
|
||||||
)
|
)
|
||||||
.help("Skip to next \(variant.displayName) reminder")
|
.help("Skip to next \(displayName) reminder")
|
||||||
.onHover { hovering in
|
.onHover { hovering in
|
||||||
isHoveredSkip = hovering
|
isHoveredSkip = hovering
|
||||||
}
|
}
|
||||||
@@ -485,7 +490,7 @@ struct TimerStatusRowWithIndividualControls: View {
|
|||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.glassEffectIfAvailable(
|
.glassEffectIfAvailable(
|
||||||
isHoveredBody
|
isHoveredBody
|
||||||
? GlassStyle.regular.tint(variant.color)
|
? GlassStyle.regular.tint(.accentColor)
|
||||||
: GlassStyle.regular,
|
: GlassStyle.regular,
|
||||||
in: .rect(cornerRadius: 6)
|
in: .rect(cornerRadius: 6)
|
||||||
)
|
)
|
||||||
@@ -493,24 +498,9 @@ struct TimerStatusRowWithIndividualControls: View {
|
|||||||
.onHover { hovering in
|
.onHover { hovering in
|
||||||
isHoveredBody = hovering
|
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") {
|
#Preview("Menu Bar Content") {
|
||||||
|
|||||||
Reference in New Issue
Block a user