fix: proper scaling of animations

This commit is contained in:
Michael Freno
2026-01-09 22:20:18 -05:00
parent d16268c34f
commit 56521833e1
9 changed files with 117 additions and 114 deletions

View File

@@ -140,14 +140,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
) )
case .blinkTriggered: case .blinkTriggered:
let sizePercentage = settingsManager?.settings.subtleReminderSizePercentage ?? 15.0
contentView = AnyView( contentView = AnyView(
BlinkReminderView { [weak self] in BlinkReminderView(sizePercentage: sizePercentage) { [weak self] in
self?.timerEngine?.dismissReminder() self?.timerEngine?.dismissReminder()
} }
) )
case .postureTriggered: case .postureTriggered:
let sizePercentage = settingsManager?.settings.subtleReminderSizePercentage ?? 10.0
contentView = AnyView( contentView = AnyView(
PostureReminderView { [weak self] in PostureReminderView(sizePercentage: sizePercentage) { [weak self] in
self?.timerEngine?.dismissReminder() self?.timerEngine?.dismissReminder()
} }
) )

View File

@@ -16,23 +16,26 @@ struct AppSettings: Codable, Equatable, Hashable {
var lookAwayCountdownSeconds: Int var lookAwayCountdownSeconds: Int
var blinkTimer: TimerConfiguration var blinkTimer: TimerConfiguration
var postureTimer: TimerConfiguration var postureTimer: TimerConfiguration
// User-defined timers (up to 3) // User-defined timers (up to 3)
var userTimers: [UserTimer] var userTimers: [UserTimer]
// UI and display settings // 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 // App state and behavior
var hasCompletedOnboarding: Bool var hasCompletedOnboarding: Bool
var launchAtLogin: Bool var launchAtLogin: Bool
var playSounds: Bool var playSounds: Bool
init( init(
lookAwayTimer: TimerConfiguration = TimerConfiguration(enabled: true, intervalSeconds: 20 * 60), lookAwayTimer: TimerConfiguration = TimerConfiguration(
enabled: true, intervalSeconds: 20 * 60),
lookAwayCountdownSeconds: Int = 20, lookAwayCountdownSeconds: Int = 20,
blinkTimer: TimerConfiguration = TimerConfiguration(enabled: false, intervalSeconds: 7 * 60), blinkTimer: TimerConfiguration = TimerConfiguration(
postureTimer: TimerConfiguration = TimerConfiguration(enabled: true, intervalSeconds: 30 * 60), enabled: false, intervalSeconds: 7 * 60),
postureTimer: TimerConfiguration = TimerConfiguration(
enabled: true, intervalSeconds: 30 * 60),
userTimers: [UserTimer] = [], userTimers: [UserTimer] = [],
subtleReminderSizePercentage: Double = 5.0, subtleReminderSizePercentage: Double = 5.0,
hasCompletedOnboarding: Bool = false, hasCompletedOnboarding: Bool = false,
@@ -50,7 +53,7 @@ struct AppSettings: Codable, Equatable, Hashable {
self.launchAtLogin = launchAtLogin self.launchAtLogin = launchAtLogin
self.playSounds = playSounds self.playSounds = playSounds
} }
static var defaults: AppSettings { static var defaults: AppSettings {
AppSettings( AppSettings(
lookAwayTimer: TimerConfiguration(enabled: true, intervalSeconds: 20 * 60), lookAwayTimer: TimerConfiguration(enabled: true, intervalSeconds: 20 * 60),
@@ -64,16 +67,14 @@ struct AppSettings: Codable, Equatable, Hashable {
playSounds: true playSounds: true
) )
} }
static func == (lhs: AppSettings, rhs: AppSettings) -> Bool { static func == (lhs: AppSettings, rhs: AppSettings) -> Bool {
lhs.lookAwayTimer == rhs.lookAwayTimer && lhs.lookAwayTimer == rhs.lookAwayTimer
lhs.lookAwayCountdownSeconds == rhs.lookAwayCountdownSeconds && && lhs.lookAwayCountdownSeconds == rhs.lookAwayCountdownSeconds
lhs.blinkTimer == rhs.blinkTimer && && lhs.blinkTimer == rhs.blinkTimer && lhs.postureTimer == rhs.postureTimer
lhs.postureTimer == rhs.postureTimer && && lhs.userTimers == rhs.userTimers
lhs.userTimers == rhs.userTimers && && lhs.subtleReminderSizePercentage == rhs.subtleReminderSizePercentage
lhs.subtleReminderSizePercentage == rhs.subtleReminderSizePercentage && && lhs.hasCompletedOnboarding == rhs.hasCompletedOnboarding
lhs.hasCompletedOnboarding == rhs.hasCompletedOnboarding && && lhs.launchAtLogin == rhs.launchAtLogin && lhs.playSounds == rhs.playSounds
lhs.launchAtLogin == rhs.launchAtLogin &&
lhs.playSounds == rhs.playSounds
} }
} }

View File

@@ -5,62 +5,48 @@
// Created by Mike Freno on 1/8/26. // Created by Mike Freno on 1/8/26.
// //
import SwiftUI
import Lottie import Lottie
import SwiftUI
struct LottieView: NSViewRepresentable { struct GazeLottieView: View {
let animationName: String let animationName: String
let loopMode: LottieLoopMode let loopMode: LottieLoopMode
let animationSpeed: CGFloat let animationSpeed: CGFloat
let onAnimationFinish: ((Bool) -> Void)?
init( init(
animationName: String, animationName: String,
loopMode: LottieLoopMode = .playOnce, loopMode: LottieLoopMode = .playOnce,
animationSpeed: CGFloat = 1.0 animationSpeed: CGFloat = 1.0,
onAnimationFinish: ((Bool) -> Void)? = nil
) { ) {
self.animationName = animationName self.animationName = animationName
self.loopMode = loopMode self.loopMode = loopMode
self.animationSpeed = animationSpeed self.animationSpeed = animationSpeed
self.onAnimationFinish = onAnimationFinish
} }
func makeNSView(context: Context) -> LottieAnimationView { var body: some View {
let animationView = LottieAnimationView()
animationView.translatesAutoresizingMaskIntoConstraints = false
if let animation = LottieAnimation.named(animationName) { if let animation = LottieAnimation.named(animationName) {
animationView.animation = animation LottieView(animation: animation)
animationView.loopMode = loopMode .playing(.fromProgress(nil, toProgress: 1, loopMode: loopMode))
animationView.animationSpeed = animationSpeed .animationSpeed(animationSpeed)
animationView.backgroundBehavior = .pauseAndRestore .animationDidFinish { completed in
animationView.play() onAnimationFinish?(completed)
} }
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") { #Preview("Lottie Preview") {
VStack(spacing: 20) { VStack(spacing: 20) {
LottieView(animationName: "blink") GazeLottieView(animationName: "blink")
.frame(width: 200, height: 200) .frame(width: 200, height: 200)
LottieView(animationName: "look-away", loopMode: .loop) GazeLottieView(animationName: "look-away", loopMode: .loop)
.frame(width: 200, height: 200) .frame(width: 200, height: 200)
LottieView(animationName: "posture") GazeLottieView(animationName: "posture")
.frame(width: 200, height: 200) .frame(width: 200, height: 200)
} }
.frame(width: 600, height: 800) .frame(width: 600, height: 800)

View File

@@ -27,7 +27,7 @@ struct PostureSetupView: View {
// Vertically centered content // Vertically centered content
Spacer() Spacer()
VStack(spacing: 30) { VStack(spacing: 30) {
Text("Maintain proper ergonomics") Text("Maintain proper ergonomics")
.font(.title3) .font(.title3)
@@ -99,7 +99,7 @@ struct PostureSetupView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
Spacer() Spacer()
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)

View File

@@ -57,18 +57,18 @@ struct SettingsOnboardingView: View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Subtle Reminder Size") Text("Subtle Reminder Size")
.font(.headline) .font(.headline)
Text("Adjust the size of blink and posture reminders") Text("Adjust the size of blink and posture reminders")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
HStack { HStack {
Slider( Slider(
value: $subtleReminderSizePercentage, value: $subtleReminderSizePercentage,
in: 2...35, in: 0.5...25,
step: 1 step: 0.5
) )
Text("\(Int(subtleReminderSizePercentage))%") Text("\(String(format: "%.1f", subtleReminderSizePercentage))%")
.frame(width: 50, alignment: .trailing) .frame(width: 50, alignment: .trailing)
.monospacedDigit() .monospacedDigit()
} }
@@ -137,7 +137,8 @@ struct SettingsOnboardingView: View {
.cornerRadius(10) .cornerRadius(10)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.glassEffect(.regular.tint(.orange).interactive(), in: .rect(cornerRadius: 10)) .glassEffect(
.regular.tint(.orange).interactive(), in: .rect(cornerRadius: 10))
} }
.padding() .padding()
} }

View File

@@ -5,29 +5,43 @@
// Created by Mike Freno on 1/7/26. // Created by Mike Freno on 1/7/26.
// //
import SwiftUI
import Lottie import Lottie
import SwiftUI
struct BlinkReminderView: View { struct BlinkReminderView: View {
let sizePercentage: Double
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 shouldShowAnimation = false
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
// For now, we'll use hardcoded size but leave framework for configuration private var baseSize: CGFloat {
// In a real implementation, this would be passed in from SettingsManager screenWidth * (sizePercentage / 100.0)
}
var body: some View { var body: some View {
VStack { VStack {
LottieView( if shouldShowAnimation {
animationName: AnimationAsset.blink.fileName, GazeLottieView(
loopMode: .playOnce, animationName: AnimationAsset.blink.fileName,
animationSpeed: 1.0 loopMode: .playOnce,
) animationSpeed: 1.0,
.frame(width: scale, height: scale) onAnimationFinish: { completed in
.shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 2) 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) .opacity(opacity)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -36,26 +50,24 @@ struct BlinkReminderView: View {
startAnimation() startAnimation()
} }
} }
private func startAnimation() { private func startAnimation() {
// Fade in and grow
withAnimation(.easeOut(duration: 0.3)) { withAnimation(.easeOut(duration: 0.3)) {
opacity = 1.0 opacity = 1.0
scale = screenWidth * 0.15 scale = 1.0
} }
// Animation duration (2 seconds for double blink) + hold time DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.3) { shouldShowAnimation = true
fadeOut()
} }
} }
private func fadeOut() { private func fadeOut() {
withAnimation(.easeOut(duration: 0.3)) { withAnimation(.easeOut(duration: 0.3)) {
opacity = 0 opacity = 0
scale = screenWidth * 0.1 scale = 0.7
} }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
onDismiss() onDismiss()
} }
@@ -63,11 +75,11 @@ struct BlinkReminderView: View {
} }
#Preview("Blink Reminder") { #Preview("Blink Reminder") {
BlinkReminderView(onDismiss: {}) BlinkReminderView(sizePercentage: 15.0, onDismiss: {})
.frame(width: 800, height: 600) .frame(width: 800, height: 600)
} }
#Preview("Blink Reminder") { #Preview("Blink Reminder") {
BlinkReminderView(onDismiss: {}) BlinkReminderView(sizePercentage: 15.0, onDismiss: {})
.frame(width: 800, height: 600) .frame(width: 800, height: 600)
} }

View File

@@ -5,71 +5,71 @@
// Created by Mike Freno on 1/7/26. // Created by Mike Freno on 1/7/26.
// //
import SwiftUI
import Lottie
import AppKit import AppKit
import Lottie
import SwiftUI
struct LookAwayReminderView: View { struct LookAwayReminderView: View {
let countdownSeconds: Int let countdownSeconds: Int
var onDismiss: () -> Void var onDismiss: () -> Void
@State private var remainingSeconds: Int @State private var remainingSeconds: Int
@State private var timer: Timer? @State private var timer: Timer?
@State private var keyMonitor: Any? @State private var keyMonitor: Any?
init(countdownSeconds: Int, onDismiss: @escaping () -> Void) { init(countdownSeconds: Int, onDismiss: @escaping () -> Void) {
self.countdownSeconds = countdownSeconds self.countdownSeconds = countdownSeconds
self.onDismiss = onDismiss self.onDismiss = onDismiss
self._remainingSeconds = State(initialValue: countdownSeconds) self._remainingSeconds = State(initialValue: countdownSeconds)
} }
var body: some View { var body: some View {
ZStack { ZStack {
// Semi-transparent dark background // Semi-transparent dark background
Color.black.opacity(0.85) Color.black.opacity(0.85)
.ignoresSafeArea() .ignoresSafeArea()
VStack(spacing: 40) { VStack(spacing: 40) {
Text("Look Away") Text("Look Away")
.font(.system(size: 64, weight: .bold)) .font(.system(size: 64, weight: .bold))
.foregroundColor(.white) .foregroundColor(.white)
Text("Look at something 20 feet away") Text("Look at something 20 feet away")
.font(.system(size: 28)) .font(.system(size: 28))
.foregroundColor(.white.opacity(0.9)) .foregroundColor(.white.opacity(0.9))
LottieView( GazeLottieView(
animationName: AnimationAsset.lookAway.fileName, animationName: AnimationAsset.lookAway.fileName,
loopMode: .loop, loopMode: .loop,
animationSpeed: 0.75 animationSpeed: 0.75
) )
.frame(width: 200, height: 200) .frame(width: 200, height: 200)
.padding(.vertical, 30) .padding(.vertical, 30)
// Countdown display // Countdown display
ZStack { ZStack {
Circle() Circle()
.stroke(Color.white.opacity(0.3), lineWidth: 8) .stroke(Color.white.opacity(0.3), lineWidth: 8)
.frame(width: 120, height: 120) .frame(width: 120, height: 120)
Circle() Circle()
.trim(from: 0, to: progress) .trim(from: 0, to: progress)
.stroke(Color.accentColor, lineWidth: 8) .stroke(Color.accentColor, lineWidth: 8)
.frame(width: 120, height: 120) .frame(width: 120, height: 120)
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.animation(.linear(duration: 1), value: progress) .animation(.linear(duration: 1), value: progress)
Text("\(remainingSeconds)") Text("\(remainingSeconds)")
.font(.system(size: 48, weight: .bold)) .font(.system(size: 48, weight: .bold))
.foregroundColor(.white) .foregroundColor(.white)
.monospacedDigit() .monospacedDigit()
} }
Text("Press ESC or Space to skip") Text("Press ESC or Space to skip")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.white.opacity(0.6)) .foregroundColor(.white.opacity(0.6))
} }
// Skip button in corner // Skip button in corner
VStack { VStack {
HStack { HStack {
@@ -94,11 +94,11 @@ struct LookAwayReminderView: View {
removeKeyMonitor() removeKeyMonitor()
} }
} }
private var progress: CGFloat { private var progress: CGFloat {
CGFloat(remainingSeconds) / CGFloat(countdownSeconds) CGFloat(remainingSeconds) / CGFloat(countdownSeconds)
} }
private func startCountdown() { private func startCountdown() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
if remainingSeconds > 0 { if remainingSeconds > 0 {
@@ -108,25 +108,25 @@ struct LookAwayReminderView: View {
} }
} }
} }
private func dismiss() { private func dismiss() {
timer?.invalidate() timer?.invalidate()
onDismiss() onDismiss()
} }
private func setupKeyMonitor() { private func setupKeyMonitor() {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
if event.keyCode == 53 { // ESC key if event.keyCode == 53 { // ESC key
dismiss() dismiss()
return nil return nil
} else if event.keyCode == 49 { // Space key } else if event.keyCode == 49 { // Space key
dismiss() dismiss()
return nil return nil
} }
return event return event
} }
} }
private func removeKeyMonitor() { private func removeKeyMonitor() {
if let monitor = keyMonitor { if let monitor = keyMonitor {
NSEvent.removeMonitor(monitor) NSEvent.removeMonitor(monitor)

View File

@@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
struct PostureReminderView: View { struct PostureReminderView: View {
let sizePercentage: Double
var onDismiss: () -> Void var onDismiss: () -> Void
@State private var scale: CGFloat = 0 @State private var scale: CGFloat = 0
@@ -34,17 +35,17 @@ struct PostureReminderView: View {
} }
private func startAnimation() { 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)) { withAnimation(.easeOut(duration: 0.4)) {
opacity = 1.0 opacity = 1.0
scale = screenWidth * 0.1 scale = screenWidth * (sizePercentage / 100.0)
} }
// Phase 2: Hold // Phase 2: Hold
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4 + 0.5) { 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)) { withAnimation(.easeInOut(duration: 0.3)) {
scale = screenWidth * 0.05 scale = screenWidth * (sizePercentage / 100.0) * 0.5
} }
// Phase 4: Shoot upward // Phase 4: Shoot upward
@@ -64,6 +65,6 @@ struct PostureReminderView: View {
} }
#Preview("Posture Reminder") { #Preview("Posture Reminder") {
PostureReminderView(onDismiss: {}) PostureReminderView(sizePercentage: 10.0, onDismiss: {})
.frame(width: 800, height: 600) .frame(width: 800, height: 600)
} }

View File

@@ -142,7 +142,7 @@ struct SettingsWindowView: View {
launchAtLogin: launchAtLogin, launchAtLogin: launchAtLogin,
playSounds: settingsManager.settings.playSounds playSounds: settingsManager.settings.playSounds
) )
// Assign the entire settings object to trigger didSet and observers // Assign the entire settings object to trigger didSet and observers
settingsManager.settings = updatedSettings settingsManager.settings = updatedSettings