feat: add lottie for animations

This commit is contained in:
Michael Freno
2026-01-08 23:01:02 -05:00
parent 587b300a3c
commit a14b7e7b99
18 changed files with 1708 additions and 67 deletions

View File

@@ -112,6 +112,7 @@
); );
name = Gaze; name = Gaze;
packageProductDependencies = ( packageProductDependencies = (
27AE10B12F10B1FC00E00DBC /* Lottie */,
); );
productName = Gaze; productName = Gaze;
productReference = 27A21B3C2F0F69DC0018C4F3 /* Gaze.app */; productReference = 27A21B3C2F0F69DC0018C4F3 /* Gaze.app */;
@@ -195,6 +196,9 @@
); );
mainGroup = 27A21B332F0F69DC0018C4F3; mainGroup = 27A21B332F0F69DC0018C4F3;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = (
27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */,
);
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 27A21B3D2F0F69DC0018C4F3 /* Products */; productRefGroup = 27A21B3D2F0F69DC0018C4F3 /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -573,6 +577,25 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* 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 */; rootObject = 27A21B342F0F69DC0018C4F3 /* Project object */;
} }

View File

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

132
Gaze/Animations/blink.json Normal file
View File

@@ -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": []
}

View File

@@ -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": []
}

View File

@@ -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": []
}

View File

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

View File

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

View File

@@ -6,39 +6,26 @@
// //
import SwiftUI import SwiftUI
import Lottie
struct BlinkReminderView: View { struct BlinkReminderView: View {
var onDismiss: () -> Void var onDismiss: () -> Void
@State private var opacity: Double = 0 @State private var opacity: Double = 0
@State private var scale: CGFloat = 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 screenHeight = NSScreen.main?.frame.height ?? 800
private let screenWidth = NSScreen.main?.frame.width ?? 1200 private let screenWidth = NSScreen.main?.frame.width ?? 1200
var body: some View { var body: some View {
VStack { VStack {
// Custom eye design for more polished look LottieView(
ZStack { animationName: AnimationAsset.blink.fileName,
// Eye outline loopMode: .playOnce,
Circle() animationSpeed: 1.0
.stroke(Color.accentColor, lineWidth: 4) )
.frame(width: scale * 1.2, height: scale * 1.2) .frame(width: scale, height: scale)
.shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 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)
} }
.opacity(opacity) .opacity(opacity)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -49,60 +36,25 @@ struct BlinkReminderView: View {
} }
private func startAnimation() { private func startAnimation() {
// Fade in and grow with spring animation for natural feel // Fade in and grow
withAnimation(.spring(duration: 0.5, bounce: 0.2)) { withAnimation(.easeOut(duration: 0.3)) {
opacity = 1.0 opacity = 1.0
scale = screenWidth * 0.12 scale = screenWidth * 0.15
} }
// Start blinking after fade in // Animation duration (2 seconds for double blink) + hold time
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { DispatchQueue.main.asyncAfter(deadline: .now() + 2.3) {
performBlinks() 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() { private func fadeOut() {
withAnimation(.spring(duration: 0.5, bounce: 0.2)) { withAnimation(.easeOut(duration: 0.3)) {
opacity = 0 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() onDismiss()
} }
} }
@@ -112,3 +64,8 @@ struct BlinkReminderView: View {
BlinkReminderView(onDismiss: {}) BlinkReminderView(onDismiss: {})
.frame(width: 800, height: 600) .frame(width: 800, height: 600)
} }
#Preview("Blink Reminder") {
BlinkReminderView(onDismiss: {})
.frame(width: 800, height: 600)
}

View File

@@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import Lottie
struct LookAwayReminderView: View { struct LookAwayReminderView: View {
let countdownSeconds: Int let countdownSeconds: Int
@@ -35,8 +36,13 @@ struct LookAwayReminderView: View {
.font(.system(size: 28)) .font(.system(size: 28))
.foregroundColor(.white.opacity(0.9)) .foregroundColor(.white.opacity(0.9))
AnimatedFaceView(size: 200) LottieView(
.padding(.vertical, 30) animationName: AnimationAsset.lookAway.fileName,
loopMode: .loop,
animationSpeed: 1.0
)
.frame(width: 200, height: 200)
.padding(.vertical, 30)
// Countdown display // Countdown display
ZStack { ZStack {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -122,4 +122,101 @@ final class SettingsManagerTests: XCTestCase {
config.intervalMinutes = 20 config.intervalMinutes = 20
XCTAssertEqual(config.intervalSeconds, 1200) 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)
}
} }