From a14b7e7b9965d818187427dfffd86896ff9b7b37 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 8 Jan 2026 23:01:02 -0500 Subject: [PATCH] feat: add lottie for animations --- Gaze.xcodeproj/project.pbxproj | 23 ++ .../xcshareddata/swiftpm/Package.resolved | 15 + Gaze/Animations/blink.json | 132 ++++++++ Gaze/Animations/look-away.json | 319 ++++++++++++++++++ Gaze/Animations/posture.json | 171 ++++++++++ Gaze/Models/AnimationAsset.swift | 18 + Gaze/Views/Components/LottieView.swift | 67 ++++ Gaze/Views/Reminders/BlinkReminderView.swift | 87 ++--- .../Reminders/LookAwayReminderView.swift | 10 +- GazeTests/Models/AnimationAssetTests.swift | 53 +++ GazeTests/Models/AppSettingsTests.swift | 175 ++++++++++ GazeTests/Models/ReminderEventTests.swift | 115 +++++++ .../Models/TimerConfigurationTests.swift | 124 +++++++ GazeTests/Models/TimerStateTests.swift | 93 +++++ GazeTests/Models/TimerTypeTests.swift | 68 ++++ .../Services/LaunchAtLoginManagerTests.swift | 71 ++++ .../Services/MigrationManagerTests.swift | 137 ++++++++ GazeTests/SettingsManagerTests.swift | 97 ++++++ 18 files changed, 1708 insertions(+), 67 deletions(-) create mode 100644 Gaze.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Gaze/Animations/blink.json create mode 100644 Gaze/Animations/look-away.json create mode 100644 Gaze/Animations/posture.json create mode 100644 Gaze/Models/AnimationAsset.swift create mode 100644 Gaze/Views/Components/LottieView.swift create mode 100644 GazeTests/Models/AnimationAssetTests.swift create mode 100644 GazeTests/Models/AppSettingsTests.swift create mode 100644 GazeTests/Models/ReminderEventTests.swift create mode 100644 GazeTests/Models/TimerConfigurationTests.swift create mode 100644 GazeTests/Models/TimerStateTests.swift create mode 100644 GazeTests/Models/TimerTypeTests.swift create mode 100644 GazeTests/Services/LaunchAtLoginManagerTests.swift create mode 100644 GazeTests/Services/MigrationManagerTests.swift diff --git a/Gaze.xcodeproj/project.pbxproj b/Gaze.xcodeproj/project.pbxproj index f828f3b..837d900 100644 --- a/Gaze.xcodeproj/project.pbxproj +++ b/Gaze.xcodeproj/project.pbxproj @@ -112,6 +112,7 @@ ); name = Gaze; packageProductDependencies = ( + 27AE10B12F10B1FC00E00DBC /* Lottie */, ); productName = Gaze; productReference = 27A21B3C2F0F69DC0018C4F3 /* Gaze.app */; @@ -195,6 +196,9 @@ ); mainGroup = 27A21B332F0F69DC0018C4F3; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 27A21B3D2F0F69DC0018C4F3 /* Products */; projectDirPath = ""; @@ -573,6 +577,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/airbnb/lottie-spm.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.6.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 27AE10B12F10B1FC00E00DBC /* Lottie */ = { + isa = XCSwiftPackageProductDependency; + package = 27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */; + productName = Lottie; + }; +/* End XCSwiftPackageProductDependency section */ + }; rootObject = 27A21B342F0F69DC0018C4F3 /* Project object */; } diff --git a/Gaze.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Gaze.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..de23afb --- /dev/null +++ b/Gaze.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "6b3386dc9ff1f3a74f1534de9c41d47137eae0901cfe819ed442f1b241549359", + "pins" : [ + { + "identity" : "lottie-spm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/airbnb/lottie-spm.git", + "state" : { + "revision" : "69faaefa7721fba9e434a52c16adf4329c9084db", + "version" : "4.6.0" + } + } + ], + "version" : 3 +} diff --git a/Gaze/Animations/blink.json b/Gaze/Animations/blink.json new file mode 100644 index 0000000..c777111 --- /dev/null +++ b/Gaze/Animations/blink.json @@ -0,0 +1,132 @@ +{ + "v": "5.7.4", + "fr": 30, + "ip": 0, + "op": 60, + "w": 200, + "h": 200, + "nm": "Blink Animation", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Eye Container", + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100}, + "r": {"a": 0, "k": 0}, + "p": {"a": 0, "k": [100, 100, 0]}, + "a": {"a": 0, "k": [0, 0, 0]}, + "s": {"a": 0, "k": [100, 100, 100]} + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "el", + "p": {"a": 0, "k": [0, 0]}, + "s": { + "a": 1, + "k": [ + {"t": 0, "s": [80, 80], "h": 1}, + {"t": 15, "s": [80, 80], "h": 1}, + {"t": 20, "s": [80, 10], "h": 1}, + {"t": 25, "s": [80, 80], "h": 1}, + {"t": 35, "s": [80, 80], "h": 1}, + {"t": 40, "s": [80, 10], "h": 1}, + {"t": 45, "s": [80, 80], "h": 1} + ] + }, + "nm": "Eye Ellipse" + }, + { + "ty": "st", + "c": {"a": 0, "k": [0, 0.478, 1, 1]}, + "o": {"a": 0, "k": 100}, + "w": {"a": 0, "k": 8}, + "lc": 2, + "lj": 2, + "nm": "Stroke" + }, + { + "ty": "tr", + "p": {"a": 0, "k": [0, 0]}, + "a": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [100, 100]}, + "r": {"a": 0, "k": 0}, + "o": {"a": 0, "k": 100} + } + ], + "nm": "Eye Shape" + } + ], + "ip": 0, + "op": 60, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "Pupil", + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100}, + "r": {"a": 0, "k": 0}, + "p": {"a": 0, "k": [100, 100, 0]}, + "a": {"a": 0, "k": [0, 0, 0]}, + "s": {"a": 0, "k": [100, 100, 100]} + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "el", + "p": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [25, 25]}, + "nm": "Pupil Ellipse" + }, + { + "ty": "fl", + "c": {"a": 0, "k": [0, 0.478, 1, 1]}, + "o": {"a": 0, "k": 100}, + "r": 1, + "nm": "Fill" + }, + { + "ty": "tr", + "p": {"a": 0, "k": [0, 0]}, + "a": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [100, 100]}, + "r": {"a": 0, "k": 0}, + "o": { + "a": 1, + "k": [ + {"t": 0, "s": [100], "h": 1}, + {"t": 20, "s": [0], "h": 1}, + {"t": 25, "s": [100], "h": 1}, + {"t": 40, "s": [0], "h": 1}, + {"t": 45, "s": [100], "h": 1} + ] + } + } + ], + "nm": "Pupil Shape" + } + ], + "ip": 0, + "op": 60, + "st": 0, + "bm": 0 + } + ], + "markers": [] +} diff --git a/Gaze/Animations/look-away.json b/Gaze/Animations/look-away.json new file mode 100644 index 0000000..b10467b --- /dev/null +++ b/Gaze/Animations/look-away.json @@ -0,0 +1,319 @@ +{ + "v": "5.7.4", + "fr": 30, + "ip": 0, + "op": 120, + "w": 200, + "h": 200, + "nm": "Look Away Animation", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Face Circle", + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100}, + "r": {"a": 0, "k": 0}, + "p": {"a": 0, "k": [100, 100, 0]}, + "a": {"a": 0, "k": [0, 0, 0]}, + "s": {"a": 0, "k": [100, 100, 100]} + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "el", + "p": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [150, 150]}, + "nm": "Face Ellipse" + }, + { + "ty": "st", + "c": {"a": 0, "k": [0, 0.478, 1, 1]}, + "o": {"a": 0, "k": 100}, + "w": {"a": 0, "k": 6}, + "lc": 2, + "lj": 2, + "nm": "Stroke" + }, + { + "ty": "tr", + "p": {"a": 0, "k": [0, 0]}, + "a": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [100, 100]}, + "r": {"a": 0, "k": 0}, + "o": {"a": 0, "k": 100} + } + ], + "nm": "Face" + } + ], + "ip": 0, + "op": 120, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "Left Eye", + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100}, + "r": {"a": 0, "k": 0}, + "p": {"a": 0, "k": [70, 85, 0]}, + "a": {"a": 0, "k": [0, 0, 0]}, + "s": {"a": 0, "k": [100, 100, 100]} + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "el", + "p": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [20, 20]}, + "nm": "Eye Ellipse" + }, + { + "ty": "st", + "c": {"a": 0, "k": [0, 0.478, 1, 1]}, + "o": {"a": 0, "k": 100}, + "w": {"a": 0, "k": 4}, + "lc": 2, + "lj": 2, + "nm": "Stroke" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + {"t": 0, "s": [0, 0], "h": 1}, + {"t": 20, "s": [-15, 0], "h": 1}, + {"t": 40, "s": [0, 0], "h": 1}, + {"t": 60, "s": [15, 0], "h": 1}, + {"t": 80, "s": [0, 0], "h": 1}, + {"t": 100, "s": [0, -10], "h": 1}, + {"t": 120, "s": [0, 0], "h": 1} + ] + }, + "a": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [100, 100]}, + "r": {"a": 0, "k": 0}, + "o": {"a": 0, "k": 100} + } + ], + "nm": "Eye" + }, + { + "ty": "gr", + "it": [ + { + "ty": "el", + "p": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [8, 8]}, + "nm": "Pupil Ellipse" + }, + { + "ty": "fl", + "c": {"a": 0, "k": [0, 0.478, 1, 1]}, + "o": {"a": 0, "k": 100}, + "r": 1, + "nm": "Fill" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + {"t": 0, "s": [0, 0], "h": 1}, + {"t": 20, "s": [-15, 0], "h": 1}, + {"t": 40, "s": [0, 0], "h": 1}, + {"t": 60, "s": [15, 0], "h": 1}, + {"t": 80, "s": [0, 0], "h": 1}, + {"t": 100, "s": [0, -10], "h": 1}, + {"t": 120, "s": [0, 0], "h": 1} + ] + }, + "a": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [100, 100]}, + "r": {"a": 0, "k": 0}, + "o": {"a": 0, "k": 100} + } + ], + "nm": "Pupil" + } + ], + "ip": 0, + "op": 120, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 4, + "nm": "Right Eye", + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100}, + "r": {"a": 0, "k": 0}, + "p": {"a": 0, "k": [130, 85, 0]}, + "a": {"a": 0, "k": [0, 0, 0]}, + "s": {"a": 0, "k": [100, 100, 100]} + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "el", + "p": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [20, 20]}, + "nm": "Eye Ellipse" + }, + { + "ty": "st", + "c": {"a": 0, "k": [0, 0.478, 1, 1]}, + "o": {"a": 0, "k": 100}, + "w": {"a": 0, "k": 4}, + "lc": 2, + "lj": 2, + "nm": "Stroke" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + {"t": 0, "s": [0, 0], "h": 1}, + {"t": 20, "s": [-15, 0], "h": 1}, + {"t": 40, "s": [0, 0], "h": 1}, + {"t": 60, "s": [15, 0], "h": 1}, + {"t": 80, "s": [0, 0], "h": 1}, + {"t": 100, "s": [0, -10], "h": 1}, + {"t": 120, "s": [0, 0], "h": 1} + ] + }, + "a": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [100, 100]}, + "r": {"a": 0, "k": 0}, + "o": {"a": 0, "k": 100} + } + ], + "nm": "Eye" + }, + { + "ty": "gr", + "it": [ + { + "ty": "el", + "p": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [8, 8]}, + "nm": "Pupil Ellipse" + }, + { + "ty": "fl", + "c": {"a": 0, "k": [0, 0.478, 1, 1]}, + "o": {"a": 0, "k": 100}, + "r": 1, + "nm": "Fill" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + {"t": 0, "s": [0, 0], "h": 1}, + {"t": 20, "s": [-15, 0], "h": 1}, + {"t": 40, "s": [0, 0], "h": 1}, + {"t": 60, "s": [15, 0], "h": 1}, + {"t": 80, "s": [0, 0], "h": 1}, + {"t": 100, "s": [0, -10], "h": 1}, + {"t": 120, "s": [0, 0], "h": 1} + ] + }, + "a": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [100, 100]}, + "r": {"a": 0, "k": 0}, + "o": {"a": 0, "k": 100} + } + ], + "nm": "Pupil" + } + ], + "ip": 0, + "op": 120, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 4, + "ty": 4, + "nm": "Smile", + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100}, + "r": {"a": 0, "k": 0}, + "p": {"a": 0, "k": [100, 120, 0]}, + "a": {"a": 0, "k": [0, 0, 0]}, + "s": {"a": 0, "k": [100, 100, 100]} + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "ks": { + "a": 0, + "k": { + "i": [[0, 0], [-20, 10], [20, 10]], + "o": [[20, 10], [20, -10], [0, 0]], + "v": [[-30, 0], [0, 15], [30, 0]], + "c": false + } + }, + "nm": "Smile Path" + }, + { + "ty": "st", + "c": {"a": 0, "k": [0, 0.478, 1, 1]}, + "o": {"a": 0, "k": 100}, + "w": {"a": 0, "k": 4}, + "lc": 2, + "lj": 2, + "nm": "Stroke" + }, + { + "ty": "tr", + "p": {"a": 0, "k": [0, 0]}, + "a": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [100, 100]}, + "r": {"a": 0, "k": 0}, + "o": {"a": 0, "k": 100} + } + ], + "nm": "Smile" + } + ], + "ip": 0, + "op": 120, + "st": 0, + "bm": 0 + } + ], + "markers": [] +} diff --git a/Gaze/Animations/posture.json b/Gaze/Animations/posture.json new file mode 100644 index 0000000..c9179a0 --- /dev/null +++ b/Gaze/Animations/posture.json @@ -0,0 +1,171 @@ +{ + "v": "5.7.4", + "fr": 30, + "ip": 0, + "op": 90, + "w": 200, + "h": 400, + "nm": "Posture Arrow Animation", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Arrow", + "sr": 1, + "ks": { + "o": { + "a": 1, + "k": [ + {"t": 0, "s": [0], "h": 1}, + {"t": 10, "s": [100], "h": 1}, + {"t": 60, "s": [100], "h": 1}, + {"t": 70, "s": [0], "h": 1} + ] + }, + "r": {"a": 0, "k": 0}, + "p": { + "a": 1, + "k": [ + {"t": 0, "s": [100, 200, 0], "h": 1}, + {"t": 60, "s": [100, 200, 0], "h": 1}, + {"t": 90, "s": [100, -100, 0], "h": 1} + ] + }, + "a": {"a": 0, "k": [0, 0, 0]}, + "s": { + "a": 1, + "k": [ + {"t": 0, "s": [0, 0, 100], "h": 1}, + {"t": 10, "s": [100, 100, 100], "h": 1}, + {"t": 60, "s": [100, 100, 100], "h": 1}, + {"t": 70, "s": [50, 50, 100], "h": 1} + ] + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "ks": { + "a": 0, + "k": { + "i": [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0]], + "o": [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0]], + "v": [ + [0, -40], + [-30, -10], + [-12, -10], + [-12, 40], + [12, 40], + [12, -10], + [30, -10] + ], + "c": true + } + }, + "nm": "Arrow Path" + }, + { + "ty": "fl", + "c": {"a": 0, "k": [0, 0, 0, 1]}, + "o": {"a": 0, "k": 100}, + "r": 1, + "nm": "Fill" + }, + { + "ty": "tr", + "p": {"a": 0, "k": [0, 0]}, + "a": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [100, 100]}, + "r": {"a": 0, "k": 0}, + "o": {"a": 0, "k": 100} + } + ], + "nm": "Arrow Shape" + } + ], + "ip": 0, + "op": 90, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "Circle Background", + "sr": 1, + "ks": { + "o": { + "a": 1, + "k": [ + {"t": 0, "s": [0], "h": 1}, + {"t": 10, "s": [100], "h": 1}, + {"t": 60, "s": [100], "h": 1}, + {"t": 70, "s": [0], "h": 1} + ] + }, + "r": {"a": 0, "k": 0}, + "p": { + "a": 1, + "k": [ + {"t": 0, "s": [100, 200, 0], "h": 1}, + {"t": 60, "s": [100, 200, 0], "h": 1}, + {"t": 90, "s": [100, -100, 0], "h": 1} + ] + }, + "a": {"a": 0, "k": [0, 0, 0]}, + "s": { + "a": 1, + "k": [ + {"t": 0, "s": [0, 0, 100], "h": 1}, + {"t": 10, "s": [100, 100, 100], "h": 1}, + {"t": 60, "s": [100, 100, 100], "h": 1}, + {"t": 70, "s": [50, 50, 100], "h": 1} + ] + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "el", + "p": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [120, 120]}, + "nm": "Circle Ellipse" + }, + { + "ty": "fl", + "c": {"a": 0, "k": [0.9, 0.9, 0.9, 1]}, + "o": {"a": 0, "k": 30}, + "r": 1, + "nm": "Fill" + }, + { + "ty": "tr", + "p": {"a": 0, "k": [0, 0]}, + "a": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [100, 100]}, + "r": {"a": 0, "k": 0}, + "o": {"a": 0, "k": 100} + } + ], + "nm": "Circle" + } + ], + "ip": 0, + "op": 90, + "st": 0, + "bm": 0 + } + ], + "markers": [] +} diff --git a/Gaze/Models/AnimationAsset.swift b/Gaze/Models/AnimationAsset.swift new file mode 100644 index 0000000..5c89b7a --- /dev/null +++ b/Gaze/Models/AnimationAsset.swift @@ -0,0 +1,18 @@ +// +// AnimationAsset.swift +// Gaze +// +// Created by Mike Freno on 1/8/26. +// + +import Foundation + +enum AnimationAsset: String { + case blink = "blink" + case lookAway = "look-away" + case posture = "posture" + + var fileName: String { + return self.rawValue + } +} diff --git a/Gaze/Views/Components/LottieView.swift b/Gaze/Views/Components/LottieView.swift new file mode 100644 index 0000000..c9eaf03 --- /dev/null +++ b/Gaze/Views/Components/LottieView.swift @@ -0,0 +1,67 @@ +// +// LottieView.swift +// Gaze +// +// Created by Mike Freno on 1/8/26. +// + +import SwiftUI +import Lottie + +struct LottieView: NSViewRepresentable { + let animationName: String + let loopMode: LottieLoopMode + let animationSpeed: CGFloat + + init( + animationName: String, + loopMode: LottieLoopMode = .playOnce, + animationSpeed: CGFloat = 1.0 + ) { + self.animationName = animationName + self.loopMode = loopMode + self.animationSpeed = animationSpeed + } + + func makeNSView(context: Context) -> LottieAnimationView { + let animationView = LottieAnimationView() + animationView.translatesAutoresizingMaskIntoConstraints = false + + if let animation = LottieAnimation.named(animationName) { + animationView.animation = animation + animationView.loopMode = loopMode + animationView.animationSpeed = animationSpeed + animationView.backgroundBehavior = .pauseAndRestore + animationView.play() + } + + return animationView + } + + func updateNSView(_ nsView: LottieAnimationView, context: Context) { + guard nsView.animation == nil || nsView.isAnimationPlaying == false else { + return + } + + if let animation = LottieAnimation.named(animationName) { + nsView.animation = animation + nsView.loopMode = loopMode + nsView.animationSpeed = animationSpeed + nsView.play() + } + } +} + +#Preview("Lottie Preview") { + VStack(spacing: 20) { + LottieView(animationName: "blink") + .frame(width: 200, height: 200) + + LottieView(animationName: "look-away", loopMode: .loop) + .frame(width: 200, height: 200) + + LottieView(animationName: "posture") + .frame(width: 200, height: 200) + } + .frame(width: 600, height: 800) +} diff --git a/Gaze/Views/Reminders/BlinkReminderView.swift b/Gaze/Views/Reminders/BlinkReminderView.swift index 653b187..800e2ad 100644 --- a/Gaze/Views/Reminders/BlinkReminderView.swift +++ b/Gaze/Views/Reminders/BlinkReminderView.swift @@ -6,39 +6,26 @@ // import SwiftUI +import Lottie struct BlinkReminderView: View { var onDismiss: () -> Void @State private var opacity: Double = 0 @State private var scale: CGFloat = 0 - @State private var blinkProgress: Double = 0 - @State private var blinkCount = 0 private let screenHeight = NSScreen.main?.frame.height ?? 800 private let screenWidth = NSScreen.main?.frame.width ?? 1200 var body: some View { VStack { - // Custom eye design for more polished look - ZStack { - // Eye outline - Circle() - .stroke(Color.accentColor, lineWidth: 4) - .frame(width: scale * 1.2, height: scale * 1.2) - - // Iris - Circle() - .fill(Color.accentColor) - .frame(width: scale * 0.6, height: scale * 0.6) - - // Pupil that moves with blink - Circle() - .fill(.black) - .frame(width: scale * 0.25, height: scale * 0.25) - .offset(y: blinkProgress * -scale * 0.1) // Vertical movement during blink - } - .shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4) + LottieView( + animationName: AnimationAsset.blink.fileName, + loopMode: .playOnce, + animationSpeed: 1.0 + ) + .frame(width: scale, height: scale) + .shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 2) } .opacity(opacity) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) @@ -49,60 +36,25 @@ struct BlinkReminderView: View { } private func startAnimation() { - // Fade in and grow with spring animation for natural feel - withAnimation(.spring(duration: 0.5, bounce: 0.2)) { + // Fade in and grow + withAnimation(.easeOut(duration: 0.3)) { opacity = 1.0 - scale = screenWidth * 0.12 + scale = screenWidth * 0.15 } - // Start blinking after fade in - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - performBlinks() + // Animation duration (2 seconds for double blink) + hold time + DispatchQueue.main.asyncAfter(deadline: .now() + 2.3) { + fadeOut() } } - private func performBlinks() { - let blinkDuration = 0.15 - let pauseBetweenBlinks = 0.2 - - func blink() { - // Close eyes with spring animation for natural movement - withAnimation(.spring(duration: blinkDuration, bounce: 0.0)) { - blinkProgress = 1.0 - } - - // Open eyes - DispatchQueue.main.asyncAfter(deadline: .now() + blinkDuration) { - withAnimation(.spring(duration: blinkDuration, bounce: 0.0)) { - blinkProgress = 0.0 - } - - blinkCount += 1 - - if blinkCount < 2 { - // Pause before next blink - DispatchQueue.main.asyncAfter(deadline: .now() + pauseBetweenBlinks) { - blink() - } - } else { - // Fade out after all blinks with smooth spring animation - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - fadeOut() - } - } - } - } - - blink() - } - private func fadeOut() { - withAnimation(.spring(duration: 0.5, bounce: 0.2)) { + withAnimation(.easeOut(duration: 0.3)) { opacity = 0 - scale = screenWidth * 0.08 + scale = screenWidth * 0.1 } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { onDismiss() } } @@ -112,3 +64,8 @@ struct BlinkReminderView: View { BlinkReminderView(onDismiss: {}) .frame(width: 800, height: 600) } + +#Preview("Blink Reminder") { + BlinkReminderView(onDismiss: {}) + .frame(width: 800, height: 600) +} diff --git a/Gaze/Views/Reminders/LookAwayReminderView.swift b/Gaze/Views/Reminders/LookAwayReminderView.swift index c55b7b2..59a57c9 100644 --- a/Gaze/Views/Reminders/LookAwayReminderView.swift +++ b/Gaze/Views/Reminders/LookAwayReminderView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Lottie struct LookAwayReminderView: View { let countdownSeconds: Int @@ -35,8 +36,13 @@ struct LookAwayReminderView: View { .font(.system(size: 28)) .foregroundColor(.white.opacity(0.9)) - AnimatedFaceView(size: 200) - .padding(.vertical, 30) + LottieView( + animationName: AnimationAsset.lookAway.fileName, + loopMode: .loop, + animationSpeed: 1.0 + ) + .frame(width: 200, height: 200) + .padding(.vertical, 30) // Countdown display ZStack { diff --git a/GazeTests/Models/AnimationAssetTests.swift b/GazeTests/Models/AnimationAssetTests.swift new file mode 100644 index 0000000..e3009d1 --- /dev/null +++ b/GazeTests/Models/AnimationAssetTests.swift @@ -0,0 +1,53 @@ +// +// AnimationAssetTests.swift +// GazeTests +// +// Created by Mike Freno on 1/8/26. +// + +import XCTest +@testable import Gaze + +final class AnimationAssetTests: XCTestCase { + + func testRawValues() { + XCTAssertEqual(AnimationAsset.blink.rawValue, "blink") + XCTAssertEqual(AnimationAsset.lookAway.rawValue, "look-away") + XCTAssertEqual(AnimationAsset.posture.rawValue, "posture") + } + + func testFileNames() { + XCTAssertEqual(AnimationAsset.blink.fileName, "blink") + XCTAssertEqual(AnimationAsset.lookAway.fileName, "look-away") + XCTAssertEqual(AnimationAsset.posture.fileName, "posture") + } + + func testFileNameMatchesRawValue() { + XCTAssertEqual(AnimationAsset.blink.fileName, AnimationAsset.blink.rawValue) + XCTAssertEqual(AnimationAsset.lookAway.fileName, AnimationAsset.lookAway.rawValue) + XCTAssertEqual(AnimationAsset.posture.fileName, AnimationAsset.posture.rawValue) + } + + func testInitFromRawValue() { + XCTAssertEqual(AnimationAsset(rawValue: "blink"), .blink) + XCTAssertEqual(AnimationAsset(rawValue: "look-away"), .lookAway) + XCTAssertEqual(AnimationAsset(rawValue: "posture"), .posture) + XCTAssertNil(AnimationAsset(rawValue: "invalid")) + } + + func testEquality() { + XCTAssertEqual(AnimationAsset.blink, AnimationAsset.blink) + XCTAssertNotEqual(AnimationAsset.blink, AnimationAsset.lookAway) + XCTAssertNotEqual(AnimationAsset.lookAway, AnimationAsset.posture) + } + + func testAllCasesExist() { + let blink = AnimationAsset.blink + let lookAway = AnimationAsset.lookAway + let posture = AnimationAsset.posture + + XCTAssertNotNil(blink) + XCTAssertNotNil(lookAway) + XCTAssertNotNil(posture) + } +} diff --git a/GazeTests/Models/AppSettingsTests.swift b/GazeTests/Models/AppSettingsTests.swift new file mode 100644 index 0000000..c904715 --- /dev/null +++ b/GazeTests/Models/AppSettingsTests.swift @@ -0,0 +1,175 @@ +// +// AppSettingsTests.swift +// GazeTests +// +// Created by Mike Freno on 1/8/26. +// + +import XCTest +@testable import Gaze + +final class AppSettingsTests: XCTestCase { + + func testDefaultSettings() { + let settings = AppSettings.defaults + + XCTAssertTrue(settings.lookAwayTimer.enabled) + XCTAssertEqual(settings.lookAwayTimer.intervalSeconds, 20 * 60) + XCTAssertEqual(settings.lookAwayCountdownSeconds, 20) + + XCTAssertTrue(settings.blinkTimer.enabled) + XCTAssertEqual(settings.blinkTimer.intervalSeconds, 5 * 60) + + XCTAssertTrue(settings.postureTimer.enabled) + XCTAssertEqual(settings.postureTimer.intervalSeconds, 30 * 60) + + XCTAssertFalse(settings.hasCompletedOnboarding) + XCTAssertFalse(settings.launchAtLogin) + XCTAssertTrue(settings.playSounds) + } + + func testEquality() { + let settings1 = AppSettings.defaults + let settings2 = AppSettings.defaults + + XCTAssertEqual(settings1, settings2) + } + + func testInequalityWhenLookAwayTimerDiffers() { + var settings1 = AppSettings.defaults + var settings2 = AppSettings.defaults + + settings2.lookAwayTimer.enabled = false + XCTAssertNotEqual(settings1, settings2) + + settings2.lookAwayTimer.enabled = true + settings2.lookAwayTimer.intervalSeconds = 10 * 60 + XCTAssertNotEqual(settings1, settings2) + } + + func testInequalityWhenCountdownDiffers() { + var settings1 = AppSettings.defaults + var settings2 = AppSettings.defaults + + settings2.lookAwayCountdownSeconds = 30 + XCTAssertNotEqual(settings1, settings2) + } + + func testInequalityWhenBlinkTimerDiffers() { + var settings1 = AppSettings.defaults + var settings2 = AppSettings.defaults + + settings2.blinkTimer.enabled = false + XCTAssertNotEqual(settings1, settings2) + } + + func testInequalityWhenPostureTimerDiffers() { + var settings1 = AppSettings.defaults + var settings2 = AppSettings.defaults + + settings2.postureTimer.intervalSeconds = 60 * 60 + XCTAssertNotEqual(settings1, settings2) + } + + func testInequalityWhenOnboardingDiffers() { + var settings1 = AppSettings.defaults + var settings2 = AppSettings.defaults + + settings2.hasCompletedOnboarding = true + XCTAssertNotEqual(settings1, settings2) + } + + func testInequalityWhenLaunchAtLoginDiffers() { + var settings1 = AppSettings.defaults + var settings2 = AppSettings.defaults + + settings2.launchAtLogin = true + XCTAssertNotEqual(settings1, settings2) + } + + func testInequalityWhenPlaySoundsDiffers() { + var settings1 = AppSettings.defaults + var settings2 = AppSettings.defaults + + settings2.playSounds = false + XCTAssertNotEqual(settings1, settings2) + } + + func testCodableEncoding() throws { + let settings = AppSettings.defaults + let encoder = JSONEncoder() + let data = try encoder.encode(settings) + + XCTAssertNotNil(data) + XCTAssertGreaterThan(data.count, 0) + } + + func testCodableDecoding() throws { + let settings = AppSettings.defaults + let encoder = JSONEncoder() + let data = try encoder.encode(settings) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(AppSettings.self, from: data) + + XCTAssertEqual(decoded, settings) + } + + func testCodableRoundTripWithModifiedSettings() throws { + var settings = AppSettings.defaults + settings.lookAwayTimer.enabled = false + settings.lookAwayCountdownSeconds = 30 + settings.blinkTimer.intervalSeconds = 10 * 60 + settings.postureTimer.enabled = false + settings.hasCompletedOnboarding = true + settings.launchAtLogin = true + settings.playSounds = false + + let encoder = JSONEncoder() + let data = try encoder.encode(settings) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(AppSettings.self, from: data) + + XCTAssertEqual(decoded, settings) + XCTAssertFalse(decoded.lookAwayTimer.enabled) + XCTAssertEqual(decoded.lookAwayCountdownSeconds, 30) + XCTAssertEqual(decoded.blinkTimer.intervalSeconds, 10 * 60) + XCTAssertFalse(decoded.postureTimer.enabled) + XCTAssertTrue(decoded.hasCompletedOnboarding) + XCTAssertTrue(decoded.launchAtLogin) + XCTAssertFalse(decoded.playSounds) + } + + func testMutability() { + var settings = AppSettings.defaults + + settings.lookAwayTimer.enabled = false + XCTAssertFalse(settings.lookAwayTimer.enabled) + + settings.lookAwayCountdownSeconds = 30 + XCTAssertEqual(settings.lookAwayCountdownSeconds, 30) + + settings.hasCompletedOnboarding = true + XCTAssertTrue(settings.hasCompletedOnboarding) + + settings.launchAtLogin = true + XCTAssertTrue(settings.launchAtLogin) + + settings.playSounds = false + XCTAssertFalse(settings.playSounds) + } + + func testBoundaryValues() { + var settings = AppSettings.defaults + + settings.lookAwayTimer.intervalSeconds = 0 + XCTAssertEqual(settings.lookAwayTimer.intervalSeconds, 0) + + settings.lookAwayCountdownSeconds = 0 + XCTAssertEqual(settings.lookAwayCountdownSeconds, 0) + + settings.lookAwayTimer.intervalSeconds = Int.max + XCTAssertEqual(settings.lookAwayTimer.intervalSeconds, Int.max) + } +} diff --git a/GazeTests/Models/ReminderEventTests.swift b/GazeTests/Models/ReminderEventTests.swift new file mode 100644 index 0000000..ce4613f --- /dev/null +++ b/GazeTests/Models/ReminderEventTests.swift @@ -0,0 +1,115 @@ +// +// ReminderEventTests.swift +// GazeTests +// +// Created by Mike Freno on 1/8/26. +// + +import XCTest +@testable import Gaze + +final class ReminderEventTests: XCTestCase { + + func testLookAwayTriggeredCreation() { + let event = ReminderEvent.lookAwayTriggered(countdownSeconds: 20) + + if case .lookAwayTriggered(let countdown) = event { + XCTAssertEqual(countdown, 20) + } else { + XCTFail("Expected lookAwayTriggered event") + } + } + + func testBlinkTriggeredCreation() { + let event = ReminderEvent.blinkTriggered + + if case .blinkTriggered = event { + XCTAssertTrue(true) + } else { + XCTFail("Expected blinkTriggered event") + } + } + + func testPostureTriggeredCreation() { + let event = ReminderEvent.postureTriggered + + if case .postureTriggered = event { + XCTAssertTrue(true) + } else { + XCTFail("Expected postureTriggered event") + } + } + + func testTypePropertyForLookAway() { + let event = ReminderEvent.lookAwayTriggered(countdownSeconds: 20) + XCTAssertEqual(event.type, .lookAway) + } + + func testTypePropertyForBlink() { + let event = ReminderEvent.blinkTriggered + XCTAssertEqual(event.type, .blink) + } + + func testTypePropertyForPosture() { + let event = ReminderEvent.postureTriggered + XCTAssertEqual(event.type, .posture) + } + + func testEquality() { + let event1 = ReminderEvent.lookAwayTriggered(countdownSeconds: 20) + let event2 = ReminderEvent.lookAwayTriggered(countdownSeconds: 20) + let event3 = ReminderEvent.lookAwayTriggered(countdownSeconds: 30) + let event4 = ReminderEvent.blinkTriggered + let event5 = ReminderEvent.blinkTriggered + let event6 = ReminderEvent.postureTriggered + + XCTAssertEqual(event1, event2) + XCTAssertNotEqual(event1, event3) + XCTAssertNotEqual(event1, event4) + XCTAssertEqual(event4, event5) + XCTAssertNotEqual(event4, event6) + } + + func testDifferentCountdownValues() { + let event1 = ReminderEvent.lookAwayTriggered(countdownSeconds: 0) + let event2 = ReminderEvent.lookAwayTriggered(countdownSeconds: 10) + let event3 = ReminderEvent.lookAwayTriggered(countdownSeconds: 60) + + XCTAssertNotEqual(event1, event2) + XCTAssertNotEqual(event2, event3) + XCTAssertNotEqual(event1, event3) + + XCTAssertEqual(event1.type, .lookAway) + XCTAssertEqual(event2.type, .lookAway) + XCTAssertEqual(event3.type, .lookAway) + } + + func testNegativeCountdown() { + let event = ReminderEvent.lookAwayTriggered(countdownSeconds: -5) + + if case .lookAwayTriggered(let countdown) = event { + XCTAssertEqual(countdown, -5) + } else { + XCTFail("Expected lookAwayTriggered event") + } + } + + func testSwitchExhaustivenessWithAllCases() { + let events: [ReminderEvent] = [ + .lookAwayTriggered(countdownSeconds: 20), + .blinkTriggered, + .postureTriggered + ] + + for event in events { + switch event { + case .lookAwayTriggered: + XCTAssertEqual(event.type, .lookAway) + case .blinkTriggered: + XCTAssertEqual(event.type, .blink) + case .postureTriggered: + XCTAssertEqual(event.type, .posture) + } + } + } +} diff --git a/GazeTests/Models/TimerConfigurationTests.swift b/GazeTests/Models/TimerConfigurationTests.swift new file mode 100644 index 0000000..48b2846 --- /dev/null +++ b/GazeTests/Models/TimerConfigurationTests.swift @@ -0,0 +1,124 @@ +// +// TimerConfigurationTests.swift +// GazeTests +// +// Created by Mike Freno on 1/8/26. +// + +import XCTest +@testable import Gaze + +final class TimerConfigurationTests: XCTestCase { + + func testInitialization() { + let config = TimerConfiguration(enabled: true, intervalSeconds: 1200) + + XCTAssertTrue(config.enabled) + XCTAssertEqual(config.intervalSeconds, 1200) + } + + func testInitializationDisabled() { + let config = TimerConfiguration(enabled: false, intervalSeconds: 600) + + XCTAssertFalse(config.enabled) + XCTAssertEqual(config.intervalSeconds, 600) + } + + func testIntervalMinutesGetter() { + let config = TimerConfiguration(enabled: true, intervalSeconds: 1200) + XCTAssertEqual(config.intervalMinutes, 20) + } + + func testIntervalMinutesSetter() { + var config = TimerConfiguration(enabled: true, intervalSeconds: 0) + config.intervalMinutes = 15 + + XCTAssertEqual(config.intervalMinutes, 15) + XCTAssertEqual(config.intervalSeconds, 900) + } + + func testIntervalMinutesConversion() { + var config = TimerConfiguration(enabled: true, intervalSeconds: 0) + + config.intervalMinutes = 1 + XCTAssertEqual(config.intervalSeconds, 60) + + config.intervalMinutes = 60 + XCTAssertEqual(config.intervalSeconds, 3600) + + config.intervalMinutes = 0 + XCTAssertEqual(config.intervalSeconds, 0) + } + + func testEquality() { + let config1 = TimerConfiguration(enabled: true, intervalSeconds: 1200) + let config2 = TimerConfiguration(enabled: true, intervalSeconds: 1200) + let config3 = TimerConfiguration(enabled: false, intervalSeconds: 1200) + let config4 = TimerConfiguration(enabled: true, intervalSeconds: 600) + + XCTAssertEqual(config1, config2) + XCTAssertNotEqual(config1, config3) + XCTAssertNotEqual(config1, config4) + } + + func testCodableEncoding() throws { + let config = TimerConfiguration(enabled: true, intervalSeconds: 1200) + let encoder = JSONEncoder() + let data = try encoder.encode(config) + + XCTAssertNotNil(data) + XCTAssertGreaterThan(data.count, 0) + } + + func testCodableDecoding() throws { + let config = TimerConfiguration(enabled: true, intervalSeconds: 1200) + let encoder = JSONEncoder() + let data = try encoder.encode(config) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(TimerConfiguration.self, from: data) + + XCTAssertEqual(decoded, config) + } + + func testCodableRoundTrip() throws { + let configs = [ + TimerConfiguration(enabled: true, intervalSeconds: 300), + TimerConfiguration(enabled: false, intervalSeconds: 1200), + TimerConfiguration(enabled: true, intervalSeconds: 1800), + TimerConfiguration(enabled: false, intervalSeconds: 0) + ] + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + for config in configs { + let data = try encoder.encode(config) + let decoded = try decoder.decode(TimerConfiguration.self, from: data) + XCTAssertEqual(decoded, config) + } + } + + func testMutability() { + var config = TimerConfiguration(enabled: true, intervalSeconds: 1200) + + config.enabled = false + XCTAssertFalse(config.enabled) + + config.intervalSeconds = 600 + XCTAssertEqual(config.intervalSeconds, 600) + XCTAssertEqual(config.intervalMinutes, 10) + } + + func testZeroInterval() { + let config = TimerConfiguration(enabled: true, intervalSeconds: 0) + XCTAssertEqual(config.intervalSeconds, 0) + XCTAssertEqual(config.intervalMinutes, 0) + } + + func testLargeInterval() { + let config = TimerConfiguration(enabled: true, intervalSeconds: 86400) + XCTAssertEqual(config.intervalSeconds, 86400) + XCTAssertEqual(config.intervalMinutes, 1440) + } +} diff --git a/GazeTests/Models/TimerStateTests.swift b/GazeTests/Models/TimerStateTests.swift new file mode 100644 index 0000000..bd54090 --- /dev/null +++ b/GazeTests/Models/TimerStateTests.swift @@ -0,0 +1,93 @@ +// +// TimerStateTests.swift +// GazeTests +// +// Created by Mike Freno on 1/8/26. +// + +import XCTest +@testable import Gaze + +final class TimerStateTests: XCTestCase { + + func testInitialization() { + let state = TimerState(type: .lookAway, intervalSeconds: 1200) + + XCTAssertEqual(state.type, .lookAway) + XCTAssertEqual(state.remainingSeconds, 1200) + XCTAssertFalse(state.isPaused) + XCTAssertTrue(state.isActive) + } + + func testInitializationWithPausedState() { + let state = TimerState(type: .blink, intervalSeconds: 300, isPaused: true) + + XCTAssertEqual(state.type, .blink) + XCTAssertEqual(state.remainingSeconds, 300) + XCTAssertTrue(state.isPaused) + XCTAssertTrue(state.isActive) + } + + func testInitializationWithInactiveState() { + let state = TimerState(type: .posture, intervalSeconds: 1800, isPaused: false, isActive: false) + + XCTAssertEqual(state.type, .posture) + XCTAssertEqual(state.remainingSeconds, 1800) + XCTAssertFalse(state.isPaused) + XCTAssertFalse(state.isActive) + } + + func testMutability() { + var state = TimerState(type: .lookAway, intervalSeconds: 1200) + + state.remainingSeconds = 600 + XCTAssertEqual(state.remainingSeconds, 600) + + state.isPaused = true + XCTAssertTrue(state.isPaused) + + state.isActive = false + XCTAssertFalse(state.isActive) + } + + func testEquality() { + let state1 = TimerState(type: .lookAway, intervalSeconds: 1200) + let state2 = TimerState(type: .lookAway, intervalSeconds: 1200) + let state3 = TimerState(type: .blink, intervalSeconds: 1200) + let state4 = TimerState(type: .lookAway, intervalSeconds: 600) + + XCTAssertEqual(state1, state2) + XCTAssertNotEqual(state1, state3) + XCTAssertNotEqual(state1, state4) + } + + func testEqualityWithDifferentPausedState() { + let state1 = TimerState(type: .lookAway, intervalSeconds: 1200, isPaused: false) + let state2 = TimerState(type: .lookAway, intervalSeconds: 1200, isPaused: true) + + XCTAssertNotEqual(state1, state2) + } + + func testEqualityWithDifferentActiveState() { + let state1 = TimerState(type: .lookAway, intervalSeconds: 1200, isActive: true) + let state2 = TimerState(type: .lookAway, intervalSeconds: 1200, isActive: false) + + XCTAssertNotEqual(state1, state2) + } + + func testZeroRemainingSeconds() { + let state = TimerState(type: .lookAway, intervalSeconds: 0) + XCTAssertEqual(state.remainingSeconds, 0) + } + + func testNegativeRemainingSeconds() { + var state = TimerState(type: .lookAway, intervalSeconds: 10) + state.remainingSeconds = -5 + XCTAssertEqual(state.remainingSeconds, -5) + } + + func testLargeIntervalSeconds() { + let state = TimerState(type: .posture, intervalSeconds: 86400) + XCTAssertEqual(state.remainingSeconds, 86400) + } +} diff --git a/GazeTests/Models/TimerTypeTests.swift b/GazeTests/Models/TimerTypeTests.swift new file mode 100644 index 0000000..443e9dc --- /dev/null +++ b/GazeTests/Models/TimerTypeTests.swift @@ -0,0 +1,68 @@ +// +// TimerTypeTests.swift +// GazeTests +// +// Created by Mike Freno on 1/8/26. +// + +import XCTest +@testable import Gaze + +final class TimerTypeTests: XCTestCase { + + func testAllCases() { + let allCases = TimerType.allCases + XCTAssertEqual(allCases.count, 3) + XCTAssertTrue(allCases.contains(.lookAway)) + XCTAssertTrue(allCases.contains(.blink)) + XCTAssertTrue(allCases.contains(.posture)) + } + + func testRawValues() { + XCTAssertEqual(TimerType.lookAway.rawValue, "lookAway") + XCTAssertEqual(TimerType.blink.rawValue, "blink") + XCTAssertEqual(TimerType.posture.rawValue, "posture") + } + + func testDisplayNames() { + XCTAssertEqual(TimerType.lookAway.displayName, "Look Away") + XCTAssertEqual(TimerType.blink.displayName, "Blink") + XCTAssertEqual(TimerType.posture.displayName, "Posture") + } + + func testIconNames() { + XCTAssertEqual(TimerType.lookAway.iconName, "eye.fill") + XCTAssertEqual(TimerType.blink.iconName, "eye.circle") + XCTAssertEqual(TimerType.posture.iconName, "figure.stand") + } + + func testIdentifiable() { + XCTAssertEqual(TimerType.lookAway.id, "lookAway") + XCTAssertEqual(TimerType.blink.id, "blink") + XCTAssertEqual(TimerType.posture.id, "posture") + } + + func testCodable() throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + for timerType in TimerType.allCases { + let encoded = try encoder.encode(timerType) + let decoded = try decoder.decode(TimerType.self, from: encoded) + XCTAssertEqual(decoded, timerType) + } + } + + func testEquality() { + XCTAssertEqual(TimerType.lookAway, TimerType.lookAway) + XCTAssertNotEqual(TimerType.lookAway, TimerType.blink) + XCTAssertNotEqual(TimerType.blink, TimerType.posture) + } + + func testInitFromRawValue() { + XCTAssertEqual(TimerType(rawValue: "lookAway"), .lookAway) + XCTAssertEqual(TimerType(rawValue: "blink"), .blink) + XCTAssertEqual(TimerType(rawValue: "posture"), .posture) + XCTAssertNil(TimerType(rawValue: "invalid")) + } +} diff --git a/GazeTests/Services/LaunchAtLoginManagerTests.swift b/GazeTests/Services/LaunchAtLoginManagerTests.swift new file mode 100644 index 0000000..ea198aa --- /dev/null +++ b/GazeTests/Services/LaunchAtLoginManagerTests.swift @@ -0,0 +1,71 @@ +// +// LaunchAtLoginManagerTests.swift +// GazeTests +// +// Created by Mike Freno on 1/8/26. +// + +import XCTest +@testable import Gaze + +final class LaunchAtLoginManagerTests: XCTestCase { + + func testIsEnabledReturnsBool() { + let isEnabled = LaunchAtLoginManager.isEnabled + XCTAssertNotNil(isEnabled) + } + + func testIsEnabledOnMacOS13AndLater() { + if #available(macOS 13.0, *) { + let isEnabled = LaunchAtLoginManager.isEnabled + XCTAssert(isEnabled == true || isEnabled == false) + } + } + + func testIsEnabledOnOlderMacOS() { + if #unavailable(macOS 13.0) { + let isEnabled = LaunchAtLoginManager.isEnabled + XCTAssertFalse(isEnabled) + } + } + + func testEnableThrowsOnUnsupportedOS() { + if #unavailable(macOS 13.0) { + XCTAssertThrowsError(try LaunchAtLoginManager.enable()) { error in + XCTAssertTrue(error is LaunchAtLoginError) + if let launchError = error as? LaunchAtLoginError { + XCTAssertEqual(launchError, .unsupportedOS) + } + } + } + } + + func testDisableThrowsOnUnsupportedOS() { + if #unavailable(macOS 13.0) { + XCTAssertThrowsError(try LaunchAtLoginManager.disable()) { error in + XCTAssertTrue(error is LaunchAtLoginError) + if let launchError = error as? LaunchAtLoginError { + XCTAssertEqual(launchError, .unsupportedOS) + } + } + } + } + + func testToggleDoesNotCrash() { + LaunchAtLoginManager.toggle() + } + + func testLaunchAtLoginErrorCases() { + let unsupportedError = LaunchAtLoginError.unsupportedOS + let registrationError = LaunchAtLoginError.registrationFailed + + XCTAssertNotEqual(unsupportedError, registrationError) + } + + func testLaunchAtLoginErrorEquality() { + let error1 = LaunchAtLoginError.unsupportedOS + let error2 = LaunchAtLoginError.unsupportedOS + + XCTAssertEqual(error1, error2) + } +} diff --git a/GazeTests/Services/MigrationManagerTests.swift b/GazeTests/Services/MigrationManagerTests.swift new file mode 100644 index 0000000..0bcf617 --- /dev/null +++ b/GazeTests/Services/MigrationManagerTests.swift @@ -0,0 +1,137 @@ +// +// MigrationManagerTests.swift +// GazeTests +// +// Created by Mike Freno on 1/8/26. +// + +import XCTest +@testable import Gaze + +final class MigrationManagerTests: XCTestCase { + + var migrationManager: MigrationManager! + + override func setUp() { + super.setUp() + migrationManager = MigrationManager() + UserDefaults.standard.removeObject(forKey: "app_version") + UserDefaults.standard.removeObject(forKey: "gazeAppSettings") + UserDefaults.standard.removeObject(forKey: "gazeAppSettings_backup") + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: "app_version") + UserDefaults.standard.removeObject(forKey: "gazeAppSettings") + UserDefaults.standard.removeObject(forKey: "gazeAppSettings_backup") + super.tearDown() + } + + func testGetCurrentVersionDefaultsToZero() { + let version = migrationManager.getCurrentVersion() + XCTAssertEqual(version, "0.0.0") + } + + func testSetCurrentVersion() { + migrationManager.setCurrentVersion("1.2.3") + let version = migrationManager.getCurrentVersion() + XCTAssertEqual(version, "1.2.3") + } + + func testMigrateSettingsReturnsNilWhenNoSettings() throws { + let result = try migrationManager.migrateSettingsIfNeeded() + XCTAssertNil(result) + } + + func testMigrateSettingsReturnsExistingDataWhenUpToDate() throws { + let testData: [String: Any] = ["test": "value"] + let jsonData = try JSONSerialization.data(withJSONObject: testData) + UserDefaults.standard.set(jsonData, forKey: "gazeAppSettings") + + if let bundleVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String { + migrationManager.setCurrentVersion(bundleVersion) + } + + let result = try migrationManager.migrateSettingsIfNeeded() + XCTAssertNotNil(result) + XCTAssertEqual(result?["test"] as? String, "value") + } + + func testMigrationErrorTypes() { + let migrationFailed = MigrationError.migrationFailed("test") + let invalidData = MigrationError.invalidDataStructure + let versionMismatch = MigrationError.versionMismatch + let noBackup = MigrationError.noBackupAvailable + + switch migrationFailed { + case .migrationFailed(let message): + XCTAssertEqual(message, "test") + default: + XCTFail("Expected migrationFailed error") + } + + XCTAssertNotNil(invalidData.errorDescription) + XCTAssertNotNil(versionMismatch.errorDescription) + XCTAssertNotNil(noBackup.errorDescription) + } + + func testMigrationErrorDescriptions() { + let errors: [MigrationError] = [ + .migrationFailed("test message"), + .invalidDataStructure, + .versionMismatch, + .noBackupAvailable + ] + + for error in errors { + XCTAssertNotNil(error.errorDescription) + XCTAssertFalse(error.errorDescription!.isEmpty) + } + } + + func testVersion101MigrationTargetVersion() { + let migration = Version101Migration() + XCTAssertEqual(migration.targetVersion, "1.0.1") + } + + func testVersion101MigrationPreservesData() throws { + let migration = Version101Migration() + let testData: [String: Any] = ["key1": "value1", "key2": 42] + + let result = try migration.migrate(testData) + + XCTAssertEqual(result["key1"] as? String, "value1") + XCTAssertEqual(result["key2"] as? Int, 42) + } + + func testMigrationThrowsOnInvalidData() { + UserDefaults.standard.set(Data("invalid json".utf8), forKey: "gazeAppSettings") + migrationManager.setCurrentVersion("0.0.0") + + XCTAssertThrowsError(try migrationManager.migrateSettingsIfNeeded()) { error in + XCTAssertTrue(error is MigrationError) + } + } + + func testVersionComparison() throws { + migrationManager.setCurrentVersion("1.0.0") + let current = migrationManager.getCurrentVersion() + XCTAssertEqual(current, "1.0.0") + + migrationManager.setCurrentVersion("1.2.3") + let updated = migrationManager.getCurrentVersion() + XCTAssertEqual(updated, "1.2.3") + } + + func testBackupIsCreatedDuringMigration() throws { + let testData: [String: Any] = ["test": "backup"] + let jsonData = try JSONSerialization.data(withJSONObject: testData) + UserDefaults.standard.set(jsonData, forKey: "gazeAppSettings") + migrationManager.setCurrentVersion("0.0.0") + + _ = try? migrationManager.migrateSettingsIfNeeded() + + let backupData = UserDefaults.standard.data(forKey: "gazeAppSettings_backup") + XCTAssertNotNil(backupData) + } +} diff --git a/GazeTests/SettingsManagerTests.swift b/GazeTests/SettingsManagerTests.swift index ddc2d13..298edb0 100644 --- a/GazeTests/SettingsManagerTests.swift +++ b/GazeTests/SettingsManagerTests.swift @@ -122,4 +122,101 @@ final class SettingsManagerTests: XCTestCase { config.intervalMinutes = 20 XCTAssertEqual(config.intervalSeconds, 1200) } + + func testSettingsAutoSaveOnChange() { + var settings = AppSettings.defaults + settings.playSounds = false + + settingsManager.settings = settings + + let savedData = UserDefaults.standard.data(forKey: "gazeAppSettings") + XCTAssertNotNil(savedData) + } + + func testMultipleTimerConfigurationUpdates() { + let config1 = TimerConfiguration(enabled: false, intervalSeconds: 600) + settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config1) + + let config2 = TimerConfiguration(enabled: true, intervalSeconds: 900) + settingsManager.updateTimerConfiguration(for: .blink, configuration: config2) + + let config3 = TimerConfiguration(enabled: false, intervalSeconds: 2400) + settingsManager.updateTimerConfiguration(for: .posture, configuration: config3) + + XCTAssertEqual(settingsManager.timerConfiguration(for: .lookAway).intervalSeconds, 600) + XCTAssertEqual(settingsManager.timerConfiguration(for: .blink).intervalSeconds, 900) + XCTAssertEqual(settingsManager.timerConfiguration(for: .posture).intervalSeconds, 2400) + + XCTAssertFalse(settingsManager.timerConfiguration(for: .lookAway).enabled) + XCTAssertTrue(settingsManager.timerConfiguration(for: .blink).enabled) + XCTAssertFalse(settingsManager.timerConfiguration(for: .posture).enabled) + } + + func testSettingsPersistenceAcrossReloads() { + var settings = AppSettings.defaults + settings.lookAwayCountdownSeconds = 45 + settings.playSounds = false + + settingsManager.settings = settings + settingsManager.load() + + XCTAssertEqual(settingsManager.settings.lookAwayCountdownSeconds, 45) + XCTAssertFalse(settingsManager.settings.playSounds) + } + + func testInvalidDataDoesNotCrashLoad() { + UserDefaults.standard.set(Data("invalid".utf8), forKey: "gazeAppSettings") + + settingsManager.load() + + XCTAssertEqual(settingsManager.settings, .defaults) + } + + func testAllTimerTypesHaveConfiguration() { + for timerType in TimerType.allCases { + let config = settingsManager.timerConfiguration(for: timerType) + XCTAssertNotNil(config) + } + } + + func testUpdateTimerConfigurationPersists() { + let newConfig = TimerConfiguration(enabled: false, intervalSeconds: 7200) + settingsManager.updateTimerConfiguration(for: .posture, configuration: newConfig) + + settingsManager.load() + + let retrieved = settingsManager.timerConfiguration(for: .posture) + XCTAssertEqual(retrieved.intervalSeconds, 7200) + XCTAssertFalse(retrieved.enabled) + } + + func testResetToDefaultsClearsAllChanges() { + settingsManager.settings.lookAwayTimer.enabled = false + settingsManager.settings.lookAwayCountdownSeconds = 60 + settingsManager.settings.blinkTimer.intervalSeconds = 10 * 60 + settingsManager.settings.postureTimer.enabled = false + settingsManager.settings.hasCompletedOnboarding = true + settingsManager.settings.launchAtLogin = true + settingsManager.settings.playSounds = false + + settingsManager.resetToDefaults() + + let defaults = AppSettings.defaults + XCTAssertEqual(settingsManager.settings, defaults) + } + + func testConcurrentAccessDoesNotCrash() { + let expectation = XCTestExpectation(description: "Concurrent access") + expectation.expectedFulfillmentCount = 10 + + for i in 0..<10 { + Task { @MainActor in + let config = TimerConfiguration(enabled: true, intervalSeconds: 300 * (i + 1)) + settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 2.0) + } }