general: filling in details
This commit is contained in:
@@ -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,12 +26,33 @@ 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 {
|
||||||
|
startTimers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onboardingCompleted() {
|
||||||
|
startTimers()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startTimers() {
|
||||||
|
guard !hasStartedTimers else { return }
|
||||||
|
hasStartedTimers = true
|
||||||
timerEngine?.start()
|
timerEngine?.start()
|
||||||
observeReminderEvents()
|
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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a backup before migration
|
|
||||||
saveBackup(settingsData)
|
saveBackup(settingsData)
|
||||||
|
|
||||||
// Apply migrations sequentially
|
|
||||||
var migratedData = settingsData
|
var migratedData = settingsData
|
||||||
|
|
||||||
for migration in migrations {
|
for migration in migrations {
|
||||||
if shouldMigrate(from: currentVersion, to: migration.targetVersion) {
|
if shouldMigrate(from: currentVersion, to: migration.targetVersion) {
|
||||||
do {
|
do {
|
||||||
migratedData = try performMigration(migration, data: migratedData)
|
migratedData = try migration.migrate(migratedData)
|
||||||
} catch {
|
} catch {
|
||||||
// If a migration fails, restore from backup and rethrow
|
|
||||||
try restoreFromBackup()
|
try restoreFromBackup()
|
||||||
throw MigrationError.migrationFailed("Migration to \(migration.targetVersion) failed")
|
throw MigrationError.migrationFailed("Migration to \(migration.targetVersion) failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the stored version
|
|
||||||
setCurrentVersion(targetVersion)
|
setCurrentVersion(targetVersion)
|
||||||
|
clearBackup()
|
||||||
|
|
||||||
return migratedData
|
return migratedData
|
||||||
|
|
||||||
} catch {
|
|
||||||
print("Migration error occurred: \(error)")
|
|
||||||
// If there's an error during migration, restore from backup if available
|
|
||||||
try? restoreFromBackup()
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 }
|
|
||||||
|
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
|
||||||
|
|
||||||
for (v1, v2) in zip(v1Components, v2Components) {
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
|
||||||
if let settingsDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
||||||
return settingsDict
|
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.set(finalData, forKey: settingsKey)
|
||||||
userDefaults.removeObject(forKey: "gazeAppSettings_backup")
|
clearBackup()
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
throw MigrationError.migrationFailed("Failed to restore from backup: \(error)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
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")
|
Image(systemName: "info.circle")
|
||||||
.foregroundColor(.blue)
|
.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))
|
||||||
|
|||||||
Reference in New Issue
Block a user