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 cancellables = Set<AnyCancellable>()
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()

View File

@@ -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()
}
}
}

View File

@@ -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..<maxLength {
let v1 = i < v1Components.count ? v1Components[i] : 0
let v2 = i < v2Components.count ? v2Components[i] : 0
if v1 > 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
}
}

View File

@@ -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)
}

View File

@@ -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))