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:
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()
}
)

View File

@@ -21,7 +21,7 @@ struct AppSettings: Codable, Equatable, Hashable {
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
@@ -29,10 +29,13 @@ struct AppSettings: Codable, Equatable, Hashable {
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,
@@ -66,14 +69,12 @@ struct AppSettings: Codable, Equatable, Hashable {
}
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
}
}

View File

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

View File

@@ -65,10 +65,10 @@ struct SettingsOnboardingView: View {
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()
}

View File

@@ -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)
@@ -38,22 +52,20 @@ struct BlinkReminderView: View {
}
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) {
@@ -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)
}

View File

@@ -5,9 +5,9 @@
// Created by Mike Freno on 1/7/26.
//
import SwiftUI
import Lottie
import AppKit
import Lottie
import SwiftUI
struct LookAwayReminderView: View {
let countdownSeconds: Int
@@ -38,7 +38,7 @@ struct LookAwayReminderView: View {
.font(.system(size: 28))
.foregroundColor(.white.opacity(0.9))
LottieView(
GazeLottieView(
animationName: AnimationAsset.lookAway.fileName,
loopMode: .loop,
animationSpeed: 0.75
@@ -116,10 +116,10 @@ struct LookAwayReminderView: View {
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
}

View File

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