diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index 419ba10..6d9bea6 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -140,14 +140,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { } ) case .blinkTriggered: + let sizePercentage = settingsManager?.settings.subtleReminderSizePercentage ?? 15.0 contentView = AnyView( - BlinkReminderView { [weak self] in + BlinkReminderView(sizePercentage: sizePercentage) { [weak self] in self?.timerEngine?.dismissReminder() } ) case .postureTriggered: + let sizePercentage = settingsManager?.settings.subtleReminderSizePercentage ?? 10.0 contentView = AnyView( - PostureReminderView { [weak self] in + PostureReminderView(sizePercentage: sizePercentage) { [weak self] in self?.timerEngine?.dismissReminder() } ) diff --git a/Gaze/Models/AppSettings.swift b/Gaze/Models/AppSettings.swift index c5a32c4..c8894e6 100644 --- a/Gaze/Models/AppSettings.swift +++ b/Gaze/Models/AppSettings.swift @@ -16,23 +16,26 @@ struct AppSettings: Codable, Equatable, Hashable { var lookAwayCountdownSeconds: Int var blinkTimer: TimerConfiguration var postureTimer: TimerConfiguration - + // User-defined timers (up to 3) var userTimers: [UserTimer] - + // UI and display settings - var subtleReminderSizePercentage: Double // 2-35% of screen width - + var subtleReminderSizePercentage: Double // 0.5-25% of screen width + // App state and behavior var hasCompletedOnboarding: Bool var launchAtLogin: Bool var playSounds: Bool - + init( - lookAwayTimer: TimerConfiguration = TimerConfiguration(enabled: true, intervalSeconds: 20 * 60), + lookAwayTimer: TimerConfiguration = TimerConfiguration( + enabled: true, intervalSeconds: 20 * 60), lookAwayCountdownSeconds: Int = 20, - blinkTimer: TimerConfiguration = TimerConfiguration(enabled: false, intervalSeconds: 7 * 60), - postureTimer: TimerConfiguration = TimerConfiguration(enabled: true, intervalSeconds: 30 * 60), + blinkTimer: TimerConfiguration = TimerConfiguration( + enabled: false, intervalSeconds: 7 * 60), + postureTimer: TimerConfiguration = TimerConfiguration( + enabled: true, intervalSeconds: 30 * 60), userTimers: [UserTimer] = [], subtleReminderSizePercentage: Double = 5.0, hasCompletedOnboarding: Bool = false, @@ -50,7 +53,7 @@ struct AppSettings: Codable, Equatable, Hashable { self.launchAtLogin = launchAtLogin self.playSounds = playSounds } - + static var defaults: AppSettings { AppSettings( lookAwayTimer: TimerConfiguration(enabled: true, intervalSeconds: 20 * 60), @@ -64,16 +67,14 @@ struct AppSettings: Codable, Equatable, Hashable { playSounds: true ) } - + static func == (lhs: AppSettings, rhs: AppSettings) -> Bool { - lhs.lookAwayTimer == rhs.lookAwayTimer && - lhs.lookAwayCountdownSeconds == rhs.lookAwayCountdownSeconds && - lhs.blinkTimer == rhs.blinkTimer && - lhs.postureTimer == rhs.postureTimer && - lhs.userTimers == rhs.userTimers && - lhs.subtleReminderSizePercentage == rhs.subtleReminderSizePercentage && - lhs.hasCompletedOnboarding == rhs.hasCompletedOnboarding && - lhs.launchAtLogin == rhs.launchAtLogin && - lhs.playSounds == rhs.playSounds + lhs.lookAwayTimer == rhs.lookAwayTimer + && lhs.lookAwayCountdownSeconds == rhs.lookAwayCountdownSeconds + && lhs.blinkTimer == rhs.blinkTimer && lhs.postureTimer == rhs.postureTimer + && lhs.userTimers == rhs.userTimers + && lhs.subtleReminderSizePercentage == rhs.subtleReminderSizePercentage + && lhs.hasCompletedOnboarding == rhs.hasCompletedOnboarding + && lhs.launchAtLogin == rhs.launchAtLogin && lhs.playSounds == rhs.playSounds } } diff --git a/Gaze/Views/Components/LottieView.swift b/Gaze/Views/Components/LottieView.swift index c9eaf03..13d0e9e 100644 --- a/Gaze/Views/Components/LottieView.swift +++ b/Gaze/Views/Components/LottieView.swift @@ -5,62 +5,48 @@ // Created by Mike Freno on 1/8/26. // -import SwiftUI import Lottie +import SwiftUI -struct LottieView: NSViewRepresentable { +struct GazeLottieView: View { let animationName: String let loopMode: LottieLoopMode let animationSpeed: CGFloat - + let onAnimationFinish: ((Bool) -> Void)? + init( animationName: String, loopMode: LottieLoopMode = .playOnce, - animationSpeed: CGFloat = 1.0 + animationSpeed: CGFloat = 1.0, + onAnimationFinish: ((Bool) -> Void)? = nil ) { self.animationName = animationName self.loopMode = loopMode self.animationSpeed = animationSpeed + self.onAnimationFinish = onAnimationFinish } - - func makeNSView(context: Context) -> LottieAnimationView { - let animationView = LottieAnimationView() - animationView.translatesAutoresizingMaskIntoConstraints = false - + + var body: some View { 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() + LottieView(animation: animation) + .playing(.fromProgress(nil, toProgress: 1, loopMode: loopMode)) + .animationSpeed(animationSpeed) + .animationDidFinish { completed in + onAnimationFinish?(completed) + } } } } #Preview("Lottie Preview") { VStack(spacing: 20) { - LottieView(animationName: "blink") + GazeLottieView(animationName: "blink") .frame(width: 200, height: 200) - - LottieView(animationName: "look-away", loopMode: .loop) + + GazeLottieView(animationName: "look-away", loopMode: .loop) .frame(width: 200, height: 200) - - LottieView(animationName: "posture") + + GazeLottieView(animationName: "posture") .frame(width: 200, height: 200) } .frame(width: 600, height: 800) diff --git a/Gaze/Views/Onboarding/PostureSetupView.swift b/Gaze/Views/Onboarding/PostureSetupView.swift index 1d7cf34..27695ea 100644 --- a/Gaze/Views/Onboarding/PostureSetupView.swift +++ b/Gaze/Views/Onboarding/PostureSetupView.swift @@ -27,7 +27,7 @@ struct PostureSetupView: View { // Vertically centered content Spacer() - + VStack(spacing: 30) { Text("Maintain proper ergonomics") .font(.title3) @@ -99,7 +99,7 @@ struct PostureSetupView: View { .foregroundColor(.secondary) } } - + Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Gaze/Views/Onboarding/SettingsOnboardingView.swift b/Gaze/Views/Onboarding/SettingsOnboardingView.swift index 3bd4ba5..8e909cc 100644 --- a/Gaze/Views/Onboarding/SettingsOnboardingView.swift +++ b/Gaze/Views/Onboarding/SettingsOnboardingView.swift @@ -57,18 +57,18 @@ struct SettingsOnboardingView: View { VStack(alignment: .leading, spacing: 12) { Text("Subtle Reminder Size") .font(.headline) - + Text("Adjust the size of blink and posture reminders") .font(.caption) .foregroundColor(.secondary) - + HStack { Slider( value: $subtleReminderSizePercentage, - in: 2...35, - step: 1 + in: 0.5...25, + step: 0.5 ) - Text("\(Int(subtleReminderSizePercentage))%") + Text("\(String(format: "%.1f", subtleReminderSizePercentage))%") .frame(width: 50, alignment: .trailing) .monospacedDigit() } @@ -137,7 +137,8 @@ struct SettingsOnboardingView: View { .cornerRadius(10) } .buttonStyle(.plain) - .glassEffect(.regular.tint(.orange).interactive(), in: .rect(cornerRadius: 10)) + .glassEffect( + .regular.tint(.orange).interactive(), in: .rect(cornerRadius: 10)) } .padding() } diff --git a/Gaze/Views/Reminders/BlinkReminderView.swift b/Gaze/Views/Reminders/BlinkReminderView.swift index e6c64e9..c778142 100644 --- a/Gaze/Views/Reminders/BlinkReminderView.swift +++ b/Gaze/Views/Reminders/BlinkReminderView.swift @@ -5,29 +5,43 @@ // Created by Mike Freno on 1/7/26. // -import SwiftUI import Lottie +import SwiftUI struct BlinkReminderView: View { + let sizePercentage: Double var onDismiss: () -> Void - + @State private var opacity: Double = 0 @State private var scale: CGFloat = 0 - + @State private var shouldShowAnimation = false + private let screenHeight = NSScreen.main?.frame.height ?? 800 private let screenWidth = NSScreen.main?.frame.width ?? 1200 - - // For now, we'll use hardcoded size but leave framework for configuration - // In a real implementation, this would be passed in from SettingsManager + + private var baseSize: CGFloat { + screenWidth * (sizePercentage / 100.0) + } + var body: some View { VStack { - 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) + if shouldShowAnimation { + GazeLottieView( + animationName: AnimationAsset.blink.fileName, + loopMode: .playOnce, + animationSpeed: 1.0, + onAnimationFinish: { completed in + if completed { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + fadeOut() + } + } + } + ) + .frame(width: baseSize, height: baseSize) + .scaleEffect(scale) + .shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 2) + } } .opacity(opacity) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) @@ -36,26 +50,24 @@ struct BlinkReminderView: View { startAnimation() } } - + private func startAnimation() { - // Fade in and grow withAnimation(.easeOut(duration: 0.3)) { opacity = 1.0 - scale = screenWidth * 0.15 + scale = 1.0 } - // Animation duration (2 seconds for double blink) + hold time - DispatchQueue.main.asyncAfter(deadline: .now() + 2.3) { - fadeOut() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + shouldShowAnimation = true } } - + private func fadeOut() { withAnimation(.easeOut(duration: 0.3)) { opacity = 0 - scale = screenWidth * 0.1 + scale = 0.7 } - + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { onDismiss() } @@ -63,11 +75,11 @@ struct BlinkReminderView: View { } #Preview("Blink Reminder") { - BlinkReminderView(onDismiss: {}) + BlinkReminderView(sizePercentage: 15.0, onDismiss: {}) .frame(width: 800, height: 600) } #Preview("Blink Reminder") { - BlinkReminderView(onDismiss: {}) + BlinkReminderView(sizePercentage: 15.0, onDismiss: {}) .frame(width: 800, height: 600) } diff --git a/Gaze/Views/Reminders/LookAwayReminderView.swift b/Gaze/Views/Reminders/LookAwayReminderView.swift index cf46138..123690d 100644 --- a/Gaze/Views/Reminders/LookAwayReminderView.swift +++ b/Gaze/Views/Reminders/LookAwayReminderView.swift @@ -5,71 +5,71 @@ // Created by Mike Freno on 1/7/26. // -import SwiftUI -import Lottie import AppKit +import Lottie +import SwiftUI struct LookAwayReminderView: View { let countdownSeconds: Int var onDismiss: () -> Void - + @State private var remainingSeconds: Int @State private var timer: Timer? @State private var keyMonitor: Any? - + init(countdownSeconds: Int, onDismiss: @escaping () -> Void) { self.countdownSeconds = countdownSeconds self.onDismiss = onDismiss self._remainingSeconds = State(initialValue: countdownSeconds) } - + var body: some View { ZStack { // Semi-transparent dark background Color.black.opacity(0.85) .ignoresSafeArea() - + VStack(spacing: 40) { Text("Look Away") .font(.system(size: 64, weight: .bold)) .foregroundColor(.white) - + Text("Look at something 20 feet away") .font(.system(size: 28)) .foregroundColor(.white.opacity(0.9)) - - LottieView( + + GazeLottieView( animationName: AnimationAsset.lookAway.fileName, loopMode: .loop, animationSpeed: 0.75 ) .frame(width: 200, height: 200) .padding(.vertical, 30) - + // Countdown display ZStack { Circle() .stroke(Color.white.opacity(0.3), lineWidth: 8) .frame(width: 120, height: 120) - + Circle() .trim(from: 0, to: progress) .stroke(Color.accentColor, lineWidth: 8) .frame(width: 120, height: 120) .rotationEffect(.degrees(-90)) .animation(.linear(duration: 1), value: progress) - + Text("\(remainingSeconds)") .font(.system(size: 48, weight: .bold)) .foregroundColor(.white) .monospacedDigit() } - + Text("Press ESC or Space to skip") .font(.subheadline) .foregroundColor(.white.opacity(0.6)) } - + // Skip button in corner VStack { HStack { @@ -94,11 +94,11 @@ struct LookAwayReminderView: View { removeKeyMonitor() } } - + private var progress: CGFloat { CGFloat(remainingSeconds) / CGFloat(countdownSeconds) } - + private func startCountdown() { timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in if remainingSeconds > 0 { @@ -108,25 +108,25 @@ struct LookAwayReminderView: View { } } } - + private func dismiss() { timer?.invalidate() onDismiss() } - + private func setupKeyMonitor() { keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - if event.keyCode == 53 { // ESC key + if event.keyCode == 53 { // ESC key dismiss() return nil - } else if event.keyCode == 49 { // Space key + } else if event.keyCode == 49 { // Space key dismiss() return nil } return event } } - + private func removeKeyMonitor() { if let monitor = keyMonitor { NSEvent.removeMonitor(monitor) diff --git a/Gaze/Views/Reminders/PostureReminderView.swift b/Gaze/Views/Reminders/PostureReminderView.swift index 252bc91..7132fcd 100644 --- a/Gaze/Views/Reminders/PostureReminderView.swift +++ b/Gaze/Views/Reminders/PostureReminderView.swift @@ -8,6 +8,7 @@ import SwiftUI struct PostureReminderView: View { + let sizePercentage: Double var onDismiss: () -> Void @State private var scale: CGFloat = 0 @@ -34,17 +35,17 @@ struct PostureReminderView: View { } private func startAnimation() { - // Phase 1: Fade in + Grow to 10% screen width + // Phase 1: Fade in + Grow to configured size withAnimation(.easeOut(duration: 0.4)) { opacity = 1.0 - scale = screenWidth * 0.1 + scale = screenWidth * (sizePercentage / 100.0) } // Phase 2: Hold DispatchQueue.main.asyncAfter(deadline: .now() + 0.4 + 0.5) { - // Phase 3: Shrink to 5% + // Phase 3: Shrink to half the configured size withAnimation(.easeInOut(duration: 0.3)) { - scale = screenWidth * 0.05 + scale = screenWidth * (sizePercentage / 100.0) * 0.5 } // Phase 4: Shoot upward @@ -64,6 +65,6 @@ struct PostureReminderView: View { } #Preview("Posture Reminder") { - PostureReminderView(onDismiss: {}) + PostureReminderView(sizePercentage: 10.0, onDismiss: {}) .frame(width: 800, height: 600) } diff --git a/Gaze/Views/SettingsWindowView.swift b/Gaze/Views/SettingsWindowView.swift index a9e131f..db2eb81 100644 --- a/Gaze/Views/SettingsWindowView.swift +++ b/Gaze/Views/SettingsWindowView.swift @@ -142,7 +142,7 @@ struct SettingsWindowView: View { launchAtLogin: launchAtLogin, playSounds: settingsManager.settings.playSounds ) - + // Assign the entire settings object to trigger didSet and observers settingsManager.settings = updatedSettings