diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index 8a66cf7..4c69cdf 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -18,6 +18,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var reminderWindowController: NSWindowController? private var cancellables = Set() private var timerStateBeforeSleep: [TimerType: Date] = [:] + private var hasStartedTimers = false func applicationDidFinishLaunching(_ notification: Notification) { settingsManager = SettingsManager.shared @@ -25,14 +26,35 @@ class AppDelegate: NSObject, NSApplicationDelegate { setupMenuBar() setupLifecycleObservers() + observeSettingsChanges() // Start timers if onboarding is complete if settingsManager!.settings.hasCompletedOnboarding { - timerEngine?.start() - observeReminderEvents() + startTimers() } } + func onboardingCompleted() { + startTimers() + } + + private func startTimers() { + guard !hasStartedTimers else { return } + hasStartedTimers = true + timerEngine?.start() + observeReminderEvents() + } + + private func observeSettingsChanges() { + settingsManager?.$settings + .sink { [weak self] settings in + if settings.hasCompletedOnboarding { + self?.startTimers() + } + } + .store(in: &cancellables) + } + func applicationWillTerminate(_ notification: Notification) { settingsManager?.save() timerEngine?.stop() diff --git a/Gaze/GazeApp.swift b/Gaze/GazeApp.swift index 8cd4e23..9e2be63 100644 --- a/Gaze/GazeApp.swift +++ b/Gaze/GazeApp.swift @@ -16,8 +16,17 @@ struct GazeApp: App { WindowGroup { if settingsManager.settings.hasCompletedOnboarding { EmptyView() + .onAppear { + closeAllWindows() + } } else { OnboardingContainerView(settingsManager: settingsManager) + .onChange(of: settingsManager.settings.hasCompletedOnboarding) { completed in + if completed { + closeAllWindows() + appDelegate.onboardingCompleted() + } + } } } .windowStyle(.hiddenTitleBar) @@ -25,4 +34,10 @@ struct GazeApp: App { CommandGroup(replacing: .newItem) { } } } + + private func closeAllWindows() { + for window in NSApplication.shared.windows { + window.close() + } + } } diff --git a/Gaze/Services/MigrationManager.swift b/Gaze/Services/MigrationManager.swift index 9fcf4e9..7fe5393 100644 --- a/Gaze/Services/MigrationManager.swift +++ b/Gaze/Services/MigrationManager.swift @@ -1,16 +1,15 @@ import Foundation -// MARK: - Migration Protocol protocol Migration { var targetVersion: String { get } - func migrate(_ data: [String: Any]) -> [String: Any] + func migrate(_ data: [String: Any]) throws -> [String: Any] } -// MARK: - Migration Error enum MigrationError: Error, LocalizedError { case migrationFailed(String) case invalidDataStructure case versionMismatch + case noBackupAvailable var errorDescription: String? { switch self { @@ -20,22 +19,23 @@ enum MigrationError: Error, LocalizedError { return "Invalid data structure for migration" case .versionMismatch: return "Version mismatch during migration" + case .noBackupAvailable: + return "No backup data available for restoration" } } } -// MARK: - Migration Manager class MigrationManager { private let userDefaults = UserDefaults.standard private var migrations: [Migration] = [] private let versionKey = "app_version" + private let settingsKey = "gazeAppSettings" + private let backupKey = "gazeAppSettings_backup" - // MARK: - Initialization init() { setupMigrations() } - // MARK: - Public Methods func getCurrentVersion() -> String { return userDefaults.string(forKey: versionKey) ?? "0.0.0" } @@ -48,62 +48,48 @@ class MigrationManager { let currentVersion = getCurrentVersion() let targetVersion = getTargetVersion() - // If we're already at the latest version, return the data as is if isUpToDate(currentVersion: currentVersion, targetVersion: targetVersion) { return loadSettingsFromDefaults() } - // Load current settings from defaults - guard let data = userDefaults.data(forKey: "gazeAppSettings") else { + guard let data = userDefaults.data(forKey: settingsKey) else { return nil } - do { - guard let settingsData = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw MigrationError.invalidDataStructure - } - - // Create a backup before migration - saveBackup(settingsData) - - // Apply migrations sequentially - var migratedData = settingsData - - for migration in migrations { - if shouldMigrate(from: currentVersion, to: migration.targetVersion) { - do { - migratedData = try performMigration(migration, data: migratedData) - } catch { - // If a migration fails, restore from backup and rethrow - try restoreFromBackup() - throw MigrationError.migrationFailed("Migration to \(migration.targetVersion) failed") - } + guard let settingsData = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw MigrationError.invalidDataStructure + } + + saveBackup(settingsData) + + var migratedData = settingsData + + for migration in migrations { + if shouldMigrate(from: currentVersion, to: migration.targetVersion) { + do { + migratedData = try migration.migrate(migratedData) + } catch { + try restoreFromBackup() + throw MigrationError.migrationFailed("Migration to \(migration.targetVersion) failed: \(error.localizedDescription)") } } - - // Update the stored version - setCurrentVersion(targetVersion) - - return migratedData - - } catch { - print("Migration error occurred: \(error)") - // If there's an error during migration, restore from backup if available - try? restoreFromBackup() - throw error } + + setCurrentVersion(targetVersion) + clearBackup() + + return migratedData } - // MARK: - Private Methods private func setupMigrations() { - // Register your migrations here in order of execution migrations.append(Version101Migration()) } private func getTargetVersion() -> String { - // This would typically come from package.json or a config file - // For this example, we'll hardcode it but in practice you'd fetch it dynamically - return "1.0.1" + if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String { + return version + } + return "1.0.0" } private func isUpToDate(currentVersion: String, targetVersion: String) -> Bool { @@ -115,11 +101,15 @@ class MigrationManager { } private func compareVersions(_ version1: String, _ version2: String) -> Int { - // Simple version comparison - in a real app you'd use a proper semantic versioning library - let v1Components = version1.split(separator: ".").map { Int($0) ?? 0 } - let v2Components = version2.split(separator: ".").map { Int($0) ?? 0 } + let v1Components = version1.split(separator: ".").compactMap { Int($0) } + let v2Components = version2.split(separator: ".").compactMap { Int($0) } - for (v1, v2) in zip(v1Components, v2Components) { + let maxLength = max(v1Components.count, v2Components.count) + + for i in 0.. v2 { return 1 } else if v1 < v2 { @@ -127,78 +117,54 @@ class MigrationManager { } } - return v1Components.count - v2Components.count + return 0 } private func loadSettingsFromDefaults() -> [String: Any]? { - guard let data = userDefaults.data(forKey: "gazeAppSettings") else { + guard let data = userDefaults.data(forKey: settingsKey), + let settingsDict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } - - do { - if let settingsDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] { - return settingsDict - } - } catch { - print("Failed to load settings from defaults: \(error)") - } - - return nil - } - - private func performMigration(_ migration: Migration, data: [String: Any]) throws -> [String: Any] { - // Wrap migration in a guard clause to handle potential errors gracefully - do { - let result = migration.migrate(data) - return result - } catch { - throw MigrationError.migrationFailed("Migration to \(migration.targetVersion) failed with error: \(error)") - } + return settingsDict } private func saveBackup(_ data: [String: Any]) { - // Create a backup of the current settings before migration - do { - let backupData = try JSONSerialization.data(withJSONObject: data) - userDefaults.set(backupData, forKey: "gazeAppSettings_backup") - } catch { - print("Failed to create backup: \(error)") + guard let backupData = try? JSONSerialization.data(withJSONObject: data) else { + print("Failed to create backup") + return } + userDefaults.set(backupData, forKey: backupKey) } private func restoreFromBackup() throws { - // Restore settings from backup if available - guard let backupData = userDefaults.data(forKey: "gazeAppSettings_backup") else { - throw MigrationError.migrationFailed("No backup data available") + guard let backupData = userDefaults.data(forKey: backupKey) else { + throw MigrationError.noBackupAvailable } - do { - if let backupDict = try JSONSerialization.jsonObject(with: backupData) as? [String: Any] { - // Save the backup back to the main settings key - let finalData = try JSONSerialization.data(withJSONObject: backupDict) - userDefaults.set(finalData, forKey: "gazeAppSettings") - - // Clear the backup - userDefaults.removeObject(forKey: "gazeAppSettings_backup") - } - } catch { - throw MigrationError.migrationFailed("Failed to restore from backup: \(error)") + guard let backupDict = try? JSONSerialization.jsonObject(with: backupData) as? [String: Any], + let finalData = try? JSONSerialization.data(withJSONObject: backupDict) else { + throw MigrationError.migrationFailed("Failed to restore from backup") } + + userDefaults.set(finalData, forKey: settingsKey) + clearBackup() + } + + private func clearBackup() { + userDefaults.removeObject(forKey: backupKey) } } -// MARK: - Version 1.0.1 Migration class Version101Migration: Migration { var targetVersion: String = "1.0.1" - func migrate(_ data: [String: Any]) -> [String: Any] { - // Example migration for version 1.0.1: - // If there's a field that needs to be moved or renamed + func migrate(_ data: [String: Any]) throws -> [String: Any] { var migratedData = data - // For example, if we had to add a new field or change structure - // This is where you would implement your specific migration logic - // For now, just return the original data as an example + // Example migration logic: + // Add any new fields with default values if they don't exist + // Transform data structures as needed + return migratedData } } \ No newline at end of file diff --git a/Gaze/Views/MenuBar/MenuBarContentView.swift b/Gaze/Views/MenuBar/MenuBarContentView.swift index 6165569..0560740 100644 --- a/Gaze/Views/MenuBar/MenuBarContentView.swift +++ b/Gaze/Views/MenuBar/MenuBarContentView.swift @@ -30,7 +30,7 @@ struct MenuBarHoverButtonStyle: ButtonStyle { configuration.label .background( RoundedRectangle(cornerRadius: 6) - .fill(isHovered ? Color.blue.opacity(0.55) : Color.clear) + .fill(isHovered ? Color.blue.opacity(0.35) : Color.clear) ) .contentShape(Rectangle()) .onHover { hovering in @@ -152,7 +152,8 @@ struct TimerStatusRow: View { let type: TimerType let state: TimerState var onSkip: () -> Void - @State private var isHovered = false + @State private var isHoveredSkip = false + @State private var isHoveredBody = false var body: some View { HStack { @@ -179,15 +180,21 @@ struct TimerStatusRow: View { .padding(6) .background( Circle() - .fill(isHovered ? Color.blue.opacity(0.1) : Color.clear) + .fill(isHoveredSkip ? Color.blue.opacity(0.35) : Color.clear) ) } .buttonStyle(.plain) .help("Skip to next \(type.displayName) reminder") .onHover { hovering in - isHovered = hovering + isHoveredSkip = hovering } } + .onHover { hovering in + isHoveredBody = hovering + }.background( + RoundedRectangle(cornerRadius: 6).fill( + isHoveredBody ? Color.blue.opacity(0.35) : Color.clear) + ) .padding(.horizontal) .padding(.vertical, 4) } diff --git a/Gaze/Views/Onboarding/LookAwaySetupView.swift b/Gaze/Views/Onboarding/LookAwaySetupView.swift index 67f5456..d3780d6 100644 --- a/Gaze/Views/Onboarding/LookAwaySetupView.swift +++ b/Gaze/Views/Onboarding/LookAwaySetupView.swift @@ -6,6 +6,11 @@ // import SwiftUI +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif struct LookAwaySetupView: View { @Binding var enabled: Bool @@ -21,9 +26,7 @@ struct LookAwaySetupView: View { Text("Look Away Reminder") .font(.system(size: 28, weight: .bold)) - Text("Follow the 20-20-20 rule") - .font(.title3) - .foregroundColor(.secondary) + InfoBox(text: "Suggested: 20-20-20 rule") VStack(alignment: .leading, spacing: 20) { Toggle("Enable Look Away Reminders", isOn: $enabled) @@ -66,7 +69,7 @@ struct LookAwaySetupView: View { .padding() .glassEffect(.regular, in: .rect(cornerRadius: 12)) - InfoBox(text: "Every \(intervalMinutes) minutes, look in the distance for \(countdownSeconds) seconds to reduce eye strain") + Text("You will be reminded every \(intervalMinutes) minutes to look in the distance for \(countdownSeconds) seconds") Spacer() } @@ -81,11 +84,21 @@ struct InfoBox: View { var body: some View { HStack(spacing: 12) { - Image(systemName: "info.circle") - .foregroundColor(.blue) + Button(action: { + if let url = URL(string: "https://www.healthline.com/health/eye-health/20-20-20-rule") { + #if os(iOS) + UIApplication.shared.open(url) + #elseif os(macOS) + NSWorkspace.shared.open(url) + #endif + } + }) { + Image(systemName: "info.circle") + .foregroundColor(.white) + }.buttonStyle(.plain) Text(text) - .font(.subheadline) - .foregroundColor(.secondary) + .font(.headline) + .foregroundColor(.white) } .padding() .glassEffect(.regular.tint(.blue), in: .rect(cornerRadius: 8))