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

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

@@ -65,10 +65,10 @@ struct SettingsOnboardingView: View {
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,30 +5,44 @@
// 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 {
GazeLottieView(
animationName: AnimationAsset.blink.fileName, animationName: AnimationAsset.blink.fileName,
loopMode: .playOnce, loopMode: .playOnce,
animationSpeed: 1.0 animationSpeed: 1.0,
onAnimationFinish: { completed in
if completed {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
fadeOut()
}
}
}
) )
.frame(width: scale, height: scale) .frame(width: baseSize, height: baseSize)
.scaleEffect(scale)
.shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 2) .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)
.padding(.top, screenHeight * 0.1) .padding(.top, screenHeight * 0.1)
@@ -38,22 +52,20 @@ struct BlinkReminderView: View {
} }
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) {
@@ -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,9 +5,9 @@
// 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
@@ -38,7 +38,7 @@ struct LookAwayReminderView: View {
.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

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