general: filling in details

This commit is contained in:
Michael Freno
2026-01-08 15:32:26 -05:00
parent acbb85314f
commit 0e43a70a66
5 changed files with 135 additions and 112 deletions

View File

@@ -18,6 +18,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private var reminderWindowController: NSWindowController? private var reminderWindowController: NSWindowController?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var timerStateBeforeSleep: [TimerType: Date] = [:] private var timerStateBeforeSleep: [TimerType: Date] = [:]
private var hasStartedTimers = false
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
settingsManager = SettingsManager.shared settingsManager = SettingsManager.shared
@@ -25,14 +26,35 @@ class AppDelegate: NSObject, NSApplicationDelegate {
setupMenuBar() setupMenuBar()
setupLifecycleObservers() setupLifecycleObservers()
observeSettingsChanges()
// Start timers if onboarding is complete // Start timers if onboarding is complete
if settingsManager!.settings.hasCompletedOnboarding { if settingsManager!.settings.hasCompletedOnboarding {
timerEngine?.start() startTimers()
observeReminderEvents()
} }
} }
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) { func applicationWillTerminate(_ notification: Notification) {
settingsManager?.save() settingsManager?.save()
timerEngine?.stop() timerEngine?.stop()

View File

@@ -16,8 +16,17 @@ struct GazeApp: App {
WindowGroup { WindowGroup {
if settingsManager.settings.hasCompletedOnboarding { if settingsManager.settings.hasCompletedOnboarding {
EmptyView() EmptyView()
.onAppear {
closeAllWindows()
}
} else { } else {
OnboardingContainerView(settingsManager: settingsManager) OnboardingContainerView(settingsManager: settingsManager)
.onChange(of: settingsManager.settings.hasCompletedOnboarding) { completed in
if completed {
closeAllWindows()
appDelegate.onboardingCompleted()
}
}
} }
} }
.windowStyle(.hiddenTitleBar) .windowStyle(.hiddenTitleBar)
@@ -25,4 +34,10 @@ struct GazeApp: App {
CommandGroup(replacing: .newItem) { } CommandGroup(replacing: .newItem) { }
} }
} }
private func closeAllWindows() {
for window in NSApplication.shared.windows {
window.close()
}
}
} }

View File

@@ -1,16 +1,15 @@
import Foundation import Foundation
// MARK: - Migration Protocol
protocol Migration { protocol Migration {
var targetVersion: String { get } 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 { enum MigrationError: Error, LocalizedError {
case migrationFailed(String) case migrationFailed(String)
case invalidDataStructure case invalidDataStructure
case versionMismatch case versionMismatch
case noBackupAvailable
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
@@ -20,22 +19,23 @@ enum MigrationError: Error, LocalizedError {
return "Invalid data structure for migration" return "Invalid data structure for migration"
case .versionMismatch: case .versionMismatch:
return "Version mismatch during migration" return "Version mismatch during migration"
case .noBackupAvailable:
return "No backup data available for restoration"
} }
} }
} }
// MARK: - Migration Manager
class MigrationManager { class MigrationManager {
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
private var migrations: [Migration] = [] private var migrations: [Migration] = []
private let versionKey = "app_version" private let versionKey = "app_version"
private let settingsKey = "gazeAppSettings"
private let backupKey = "gazeAppSettings_backup"
// MARK: - Initialization
init() { init() {
setupMigrations() setupMigrations()
} }
// MARK: - Public Methods
func getCurrentVersion() -> String { func getCurrentVersion() -> String {
return userDefaults.string(forKey: versionKey) ?? "0.0.0" return userDefaults.string(forKey: versionKey) ?? "0.0.0"
} }
@@ -48,62 +48,48 @@ class MigrationManager {
let currentVersion = getCurrentVersion() let currentVersion = getCurrentVersion()
let targetVersion = getTargetVersion() let targetVersion = getTargetVersion()
// If we're already at the latest version, return the data as is
if isUpToDate(currentVersion: currentVersion, targetVersion: targetVersion) { if isUpToDate(currentVersion: currentVersion, targetVersion: targetVersion) {
return loadSettingsFromDefaults() return loadSettingsFromDefaults()
} }
// Load current settings from defaults guard let data = userDefaults.data(forKey: settingsKey) else {
guard let data = userDefaults.data(forKey: "gazeAppSettings") else {
return nil return nil
} }
do { guard let settingsData = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
guard let settingsData = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw MigrationError.invalidDataStructure
throw MigrationError.invalidDataStructure }
}
saveBackup(settingsData)
// Create a backup before migration
saveBackup(settingsData) var migratedData = settingsData
// Apply migrations sequentially for migration in migrations {
var migratedData = settingsData if shouldMigrate(from: currentVersion, to: migration.targetVersion) {
do {
for migration in migrations { migratedData = try migration.migrate(migratedData)
if shouldMigrate(from: currentVersion, to: migration.targetVersion) { } catch {
do { try restoreFromBackup()
migratedData = try performMigration(migration, data: migratedData) throw MigrationError.migrationFailed("Migration to \(migration.targetVersion) failed: \(error.localizedDescription)")
} catch {
// If a migration fails, restore from backup and rethrow
try restoreFromBackup()
throw MigrationError.migrationFailed("Migration to \(migration.targetVersion) failed")
}
} }
} }
// 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() { private func setupMigrations() {
// Register your migrations here in order of execution
migrations.append(Version101Migration()) migrations.append(Version101Migration())
} }
private func getTargetVersion() -> String { private func getTargetVersion() -> String {
// This would typically come from package.json or a config file if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String {
// For this example, we'll hardcode it but in practice you'd fetch it dynamically return version
return "1.0.1" }
return "1.0.0"
} }
private func isUpToDate(currentVersion: String, targetVersion: String) -> Bool { private func isUpToDate(currentVersion: String, targetVersion: String) -> Bool {
@@ -115,11 +101,15 @@ class MigrationManager {
} }
private func compareVersions(_ version1: String, _ version2: String) -> Int { 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: ".").compactMap { Int($0) }
let v1Components = version1.split(separator: ".").map { Int($0) ?? 0 } let v2Components = version2.split(separator: ".").compactMap { Int($0) }
let v2Components = version2.split(separator: ".").map { Int($0) ?? 0 }
for (v1, v2) in zip(v1Components, v2Components) { let maxLength = max(v1Components.count, v2Components.count)
for i in 0..<maxLength {
let v1 = i < v1Components.count ? v1Components[i] : 0
let v2 = i < v2Components.count ? v2Components[i] : 0
if v1 > v2 { if v1 > v2 {
return 1 return 1
} else if v1 < v2 { } else if v1 < v2 {
@@ -127,78 +117,54 @@ class MigrationManager {
} }
} }
return v1Components.count - v2Components.count return 0
} }
private func loadSettingsFromDefaults() -> [String: Any]? { 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 return nil
} }
return settingsDict
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)")
}
} }
private func saveBackup(_ data: [String: Any]) { private func saveBackup(_ data: [String: Any]) {
// Create a backup of the current settings before migration guard let backupData = try? JSONSerialization.data(withJSONObject: data) else {
do { print("Failed to create backup")
let backupData = try JSONSerialization.data(withJSONObject: data) return
userDefaults.set(backupData, forKey: "gazeAppSettings_backup")
} catch {
print("Failed to create backup: \(error)")
} }
userDefaults.set(backupData, forKey: backupKey)
} }
private func restoreFromBackup() throws { private func restoreFromBackup() throws {
// Restore settings from backup if available guard let backupData = userDefaults.data(forKey: backupKey) else {
guard let backupData = userDefaults.data(forKey: "gazeAppSettings_backup") else { throw MigrationError.noBackupAvailable
throw MigrationError.migrationFailed("No backup data available")
} }
do { guard let backupDict = try? JSONSerialization.jsonObject(with: backupData) as? [String: Any],
if let backupDict = try JSONSerialization.jsonObject(with: backupData) as? [String: Any] { let finalData = try? JSONSerialization.data(withJSONObject: backupDict) else {
// Save the backup back to the main settings key throw MigrationError.migrationFailed("Failed to restore from backup")
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)")
} }
userDefaults.set(finalData, forKey: settingsKey)
clearBackup()
}
private func clearBackup() {
userDefaults.removeObject(forKey: backupKey)
} }
} }
// MARK: - Version 1.0.1 Migration
class Version101Migration: Migration { class Version101Migration: Migration {
var targetVersion: String = "1.0.1" var targetVersion: String = "1.0.1"
func migrate(_ data: [String: Any]) -> [String: Any] { func migrate(_ data: [String: Any]) throws -> [String: Any] {
// Example migration for version 1.0.1:
// If there's a field that needs to be moved or renamed
var migratedData = data var migratedData = data
// For example, if we had to add a new field or change structure // Example migration logic:
// This is where you would implement your specific migration logic // Add any new fields with default values if they don't exist
// For now, just return the original data as an example // Transform data structures as needed
return migratedData return migratedData
} }
} }

View File

@@ -30,7 +30,7 @@ struct MenuBarHoverButtonStyle: ButtonStyle {
configuration.label configuration.label
.background( .background(
RoundedRectangle(cornerRadius: 6) RoundedRectangle(cornerRadius: 6)
.fill(isHovered ? Color.blue.opacity(0.55) : Color.clear) .fill(isHovered ? Color.blue.opacity(0.35) : Color.clear)
) )
.contentShape(Rectangle()) .contentShape(Rectangle())
.onHover { hovering in .onHover { hovering in
@@ -152,7 +152,8 @@ struct TimerStatusRow: View {
let type: TimerType let type: TimerType
let state: TimerState let state: TimerState
var onSkip: () -> Void var onSkip: () -> Void
@State private var isHovered = false @State private var isHoveredSkip = false
@State private var isHoveredBody = false
var body: some View { var body: some View {
HStack { HStack {
@@ -179,15 +180,21 @@ struct TimerStatusRow: View {
.padding(6) .padding(6)
.background( .background(
Circle() Circle()
.fill(isHovered ? Color.blue.opacity(0.1) : Color.clear) .fill(isHoveredSkip ? Color.blue.opacity(0.35) : Color.clear)
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Skip to next \(type.displayName) reminder") .help("Skip to next \(type.displayName) reminder")
.onHover { hovering in .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(.horizontal)
.padding(.vertical, 4) .padding(.vertical, 4)
} }

View File

@@ -6,6 +6,11 @@
// //
import SwiftUI import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
struct LookAwaySetupView: View { struct LookAwaySetupView: View {
@Binding var enabled: Bool @Binding var enabled: Bool
@@ -21,9 +26,7 @@ struct LookAwaySetupView: View {
Text("Look Away Reminder") Text("Look Away Reminder")
.font(.system(size: 28, weight: .bold)) .font(.system(size: 28, weight: .bold))
Text("Follow the 20-20-20 rule") InfoBox(text: "Suggested: 20-20-20 rule")
.font(.title3)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
Toggle("Enable Look Away Reminders", isOn: $enabled) Toggle("Enable Look Away Reminders", isOn: $enabled)
@@ -66,7 +69,7 @@ struct LookAwaySetupView: View {
.padding() .padding()
.glassEffect(.regular, in: .rect(cornerRadius: 12)) .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() Spacer()
} }
@@ -81,11 +84,21 @@ struct InfoBox: View {
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "info.circle") Button(action: {
.foregroundColor(.blue) 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) Text(text)
.font(.subheadline) .font(.headline)
.foregroundColor(.secondary) .foregroundColor(.white)
} }
.padding() .padding()
.glassEffect(.regular.tint(.blue), in: .rect(cornerRadius: 8)) .glassEffect(.regular.tint(.blue), in: .rect(cornerRadius: 8))