Working Animations

This commit is contained in:
Hariharan Mudaliar 2025-10-11 15:45:58 +05:30
commit b7959b78fc
6 changed files with 126 additions and 69 deletions

1
.gitignore vendored
View file

@ -82,3 +82,4 @@ PrivateWorks
*.sh *.sh
comparison_report.json comparison_report.json
*.txt *.txt
ANIMATION_DEBUG_GUIDE.md

View file

@ -13,12 +13,13 @@ import Defaults
struct IdleAnimationView: View { struct IdleAnimationView: View {
@Default(.selectedIdleAnimation) var selectedAnimation @Default(.selectedIdleAnimation) var selectedAnimation
@Default(.animationTransformOverrides) var overrides
var body: some View { var body: some View {
Group { Group {
if let animation = selectedAnimation { if let animation = selectedAnimation {
AnimationContentView(animation: animation) AnimationContentView(animation: animation)
.id("\(animation.id)-\(animation.hashValue)") // Force complete recreation when ANY property changes .id("\(animation.id)-\(overrides[animation.id.uuidString]?.hashValue ?? 0)") // Force recreation when override changes
} else { } else {
// Fallback to original face if nothing selected // Fallback to original face if nothing selected
MinimalFaceFeatures(height: 20, width: 30) MinimalFaceFeatures(height: 20, width: 30)
@ -32,7 +33,11 @@ private struct AnimationContentView: View {
let animation: CustomIdleAnimation let animation: CustomIdleAnimation
var body: some View { var body: some View {
let config = animation.transformConfig let config = animation.getTransformConfig()
// Debug logging
let _ = print("🎨 [IdleAnimationView] Rendering animation: \(animation.name)")
let _ = print("🎨 [IdleAnimationView] Config: scale=\(config.scale), offset=(\(config.offsetX), \(config.offsetY)), opacity=\(config.opacity)")
switch animation.source { switch animation.source {
case .lottieFile(let url): case .lottieFile(let url):
@ -72,7 +77,18 @@ private struct AnimationContentView: View {
.clipped() .clipped()
case .builtInFace: case .builtInFace:
// Apply transforms to built-in face too!
MinimalFaceFeatures(height: 20, width: 30) MinimalFaceFeatures(height: 20, width: 30)
.frame(
width: config.cropWidth * config.scale,
height: config.cropHeight * config.scale
)
.offset(x: config.offsetX, y: config.offsetY)
.rotationEffect(.degrees(config.rotation))
.opacity(config.opacity)
.padding(.bottom, config.paddingBottom)
.frame(width: config.expandWithAnimation ? nil : 30, height: 20)
.clipped()
} }
} }
} }

View file

@ -48,7 +48,9 @@ struct AnimationEditorView: View {
if let existing = existingAnimation { if let existing = existingAnimation {
_name = State(initialValue: existing.name) _name = State(initialValue: existing.name)
_speed = State(initialValue: existing.speed) _speed = State(initialValue: existing.speed)
let config = existing.transformConfig
// Load from overrides if they exist
let config = Defaults[.animationTransformOverrides][existing.id.uuidString] ?? .default
_scale = State(initialValue: config.scale) _scale = State(initialValue: config.scale)
_offsetX = State(initialValue: config.offsetX) _offsetX = State(initialValue: config.offsetX)
_offsetY = State(initialValue: config.offsetY) _offsetY = State(initialValue: config.offsetY)
@ -106,17 +108,30 @@ struct AnimationEditorView: View {
.frame(width: 30 * previewScale, height: 20 * previewScale) .frame(width: 30 * previewScale, height: 20 * previewScale)
// Animation preview with transformations // Animation preview with transformations
LottieView(state: LUStateData( if let existing = existingAnimation, case .builtInFace = existing.source {
type: .loadedFrom(sourceURL), // Show built-in face
speed: speed, MinimalFaceFeatures(height: 20, width: 30)
loopMode: loopMode.lottieLoopMode .scaleEffect(previewScale)
)) .frame(width: cropWidth * scale * previewScale, height: cropHeight * scale * previewScale)
.frame(width: cropWidth * scale * previewScale, height: cropHeight * scale * previewScale) .offset(x: offsetX * previewScale, y: offsetY * previewScale)
.offset(x: offsetX * previewScale, y: offsetY * previewScale) .rotationEffect(.degrees(rotation))
.rotationEffect(.degrees(rotation)) .opacity(opacity)
.opacity(opacity) .padding(.bottom, paddingBottom * previewScale)
.padding(.bottom, paddingBottom * previewScale) .clipped()
.clipped() } else {
// Show Lottie animation
LottieView(state: LUStateData(
type: .loadedFrom(sourceURL),
speed: speed,
loopMode: loopMode.lottieLoopMode
))
.frame(width: cropWidth * scale * previewScale, height: cropHeight * scale * previewScale)
.offset(x: offsetX * previewScale, y: offsetY * previewScale)
.rotationEffect(.degrees(rotation))
.opacity(opacity)
.padding(.bottom, paddingBottom * previewScale)
.clipped()
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding() .padding()
@ -475,18 +490,26 @@ struct AnimationEditorView: View {
loopMode: loopMode loopMode: loopMode
) )
// If editing existing animation, create a NEW animation object with updated values // If editing existing animation, save transform config to overrides
if let existing = existingAnimation { if let existing = existingAnimation {
// Create completely new animation to force view refresh // Store only the transform config override
let updatedAnimation = CustomIdleAnimation( var overrides = Defaults[.animationTransformOverrides]
id: existing.id, // Keep same ID so it replaces the right one overrides[existing.id.uuidString] = config
name: name, Defaults[.animationTransformOverrides] = overrides
source: existing.source,
speed: speed, print("✅ [AnimationEditor] Saved transform override for: \(existing.name)")
isBuiltIn: existing.isBuiltIn, print("✅ [AnimationEditor] Override: \(config)")
transformConfig: config
) // Force view refresh by updating selectedIdleAnimation if this is the selected one
animation = updatedAnimation if Defaults[.selectedIdleAnimation]?.id == existing.id {
// Trigger refresh by re-setting the same animation
let current = Defaults[.selectedIdleAnimation]
Defaults[.selectedIdleAnimation] = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
Defaults[.selectedIdleAnimation] = current
}
}
dismiss() dismiss()
return return
} }
@ -495,8 +518,7 @@ struct AnimationEditorView: View {
let result = IdleAnimationManager.shared.importLottieFile( let result = IdleAnimationManager.shared.importLottieFile(
from: sourceURL, from: sourceURL,
name: name, name: name,
speed: speed, speed: speed
transformConfig: config
) )
switch result { switch result {

View file

@ -51,16 +51,31 @@ struct IdleAnimationsSettingsSection: View {
selectedForDeletion = animation selectedForDeletion = animation
showingDeleteAlert = true showingDeleteAlert = true
}, },
onEdit: animation.isBuiltIn ? nil : { onEdit: {
print("🔧 [Edit] Attempting to edit animation: \(animation.name)")
print("🔧 [Edit] Animation source: \(animation.source)")
editingExistingAnimation = animation editingExistingAnimation = animation
if case .lottieFile(let url) = animation.source {
switch animation.source {
case .lottieFile(let url):
print("🔧 [Edit] Lottie file URL: \(url)")
print("🔧 [Edit] File exists: \(FileManager.default.fileExists(atPath: url.path))")
editorSourceURL = url editorSourceURL = url
editorIsRemote = false editorIsRemote = false
showingEditor = true showingEditor = true
} else if case .lottieURL(let url) = animation.source {
case .lottieURL(let url):
print("🔧 [Edit] Lottie URL: \(url)")
editorSourceURL = url editorSourceURL = url
editorIsRemote = true editorIsRemote = true
showingEditor = true showingEditor = true
case .builtInFace:
print("🔧 [Edit] Built-in face - using dummy URL")
// Use a dummy URL for built-in face (editor only needs URL for preview)
editorSourceURL = URL(string: "builtin://face")!
editorIsRemote = false
showingEditor = true
} }
} }
) )

View file

@ -77,39 +77,34 @@ class IdleAnimationManager {
/// Load animations from the LottieAnimations folder in the bundle /// Load animations from the LottieAnimations folder in the bundle
private func loadBundledAnimations() -> [CustomIdleAnimation]? { private func loadBundledAnimations() -> [CustomIdleAnimation]? {
guard let bundlePath = Bundle.main.resourcePath else { print("📦 [IdleAnimationManager] Loading bundled animations...")
print("⚠️ [IdleAnimationManager] Could not find bundle resource path")
return nil
}
let lottieFolder = URL(fileURLWithPath: bundlePath).appendingPathComponent("LottieAnimations") // The JSON files are added as individual resources, not in a folder
let bundledFiles = ["Dog waiting", "Moody Dog", "Orange Cat Peeping"]
var animations: [CustomIdleAnimation] = []
guard FileManager.default.fileExists(atPath: lottieFolder.path) else { for filename in bundledFiles {
print("⚠️ [IdleAnimationManager] LottieAnimations folder not found at: \(lottieFolder.path)") if let url = Bundle.main.url(forResource: filename, withExtension: "json") {
return nil let animation = CustomIdleAnimation(
} name: filename,
do {
let files = try FileManager.default.contentsOfDirectory(at: lottieFolder, includingPropertiesForKeys: nil)
let jsonFiles = files.filter { $0.pathExtension.lowercased() == "json" }
let animations = jsonFiles.map { url -> CustomIdleAnimation in
let name = url.deletingPathExtension().lastPathComponent
return CustomIdleAnimation(
name: name,
source: .lottieFile(url), source: .lottieFile(url),
speed: 1.0, speed: 1.0,
isBuiltIn: true isBuiltIn: true
) )
animations.append(animation)
print("✅ [IdleAnimationManager] Loaded bundled animation: \(filename)")
} else {
print("⚠️ [IdleAnimationManager] Could not find bundled animation: \(filename).json")
} }
}
print("📦 [IdleAnimationManager] Loaded \(animations.count) bundled animations") guard !animations.isEmpty else {
return animations print("⚠️ [IdleAnimationManager] No bundled animations found")
} catch {
print("❌ [IdleAnimationManager] Error loading bundled animations: \(error)")
return nil return nil
} }
print("📦 [IdleAnimationManager] Loaded \(animations.count) bundled animations")
return animations
} }
// MARK: - User Animations // MARK: - User Animations
@ -144,20 +139,20 @@ class IdleAnimationManager {
// MARK: - Import & Export // MARK: - Import & Export
/// Import a Lottie JSON file from URL (either local file or download from remote) /// Import a Lottie JSON file from URL (either local file or download from remote)
func importLottieFile(from url: URL, name: String? = nil, speed: CGFloat = 1.0, transformConfig: AnimationTransformConfig = .default) -> Result<CustomIdleAnimation, Error> { func importLottieFile(from url: URL, name: String? = nil, speed: CGFloat = 1.0) -> Result<CustomIdleAnimation, Error> {
let fileName = name ?? url.deletingPathExtension().lastPathComponent let fileName = name ?? url.deletingPathExtension().lastPathComponent
// If it's a remote URL, download it first // If it's a remote URL, download it first
if url.scheme == "http" || url.scheme == "https" { if url.scheme == "http" || url.scheme == "https" {
return importRemoteAnimation(from: url, name: fileName, speed: speed, transformConfig: transformConfig) return importRemoteAnimation(from: url, name: fileName, speed: speed)
} }
// Local file import // Local file import
return importLocalFile(from: url, name: fileName, speed: speed, transformConfig: transformConfig) return importLocalFile(from: url, name: fileName, speed: speed)
} }
/// Import a local Lottie JSON file /// Import a local Lottie JSON file
private func importLocalFile(from sourceURL: URL, name: String, speed: CGFloat, transformConfig: AnimationTransformConfig) -> Result<CustomIdleAnimation, Error> { private func importLocalFile(from sourceURL: URL, name: String, speed: CGFloat) -> Result<CustomIdleAnimation, Error> {
do { do {
// Validate it's a JSON file // Validate it's a JSON file
guard sourceURL.pathExtension.lowercased() == "json" else { guard sourceURL.pathExtension.lowercased() == "json" else {
@ -177,13 +172,12 @@ class IdleAnimationManager {
// Copy file to storage // Copy file to storage
try FileManager.default.copyItem(at: sourceURL, to: destinationURL) try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
// Create animation object // Create animation object (transforms will be stored separately)
let animation = CustomIdleAnimation( let animation = CustomIdleAnimation(
name: name, name: name,
source: .lottieFile(destinationURL), source: .lottieFile(destinationURL),
speed: speed, speed: speed,
isBuiltIn: false, isBuiltIn: false
transformConfig: transformConfig
) )
// Add to defaults // Add to defaults
@ -201,7 +195,7 @@ class IdleAnimationManager {
} }
/// Import animation from remote URL /// Import animation from remote URL
private func importRemoteAnimation(from url: URL, name: String, speed: CGFloat, transformConfig: AnimationTransformConfig) -> Result<CustomIdleAnimation, Error> { private func importRemoteAnimation(from url: URL, name: String, speed: CGFloat) -> Result<CustomIdleAnimation, Error> {
// For remote URLs, we store the URL directly (no download) // For remote URLs, we store the URL directly (no download)
// The LottieView will handle downloading when needed // The LottieView will handle downloading when needed
@ -209,8 +203,7 @@ class IdleAnimationManager {
name: name, name: name,
source: .lottieURL(url), source: .lottieURL(url),
speed: speed, speed: speed,
isBuiltIn: false, isBuiltIn: false
transformConfig: transformConfig
) )
// Add to defaults // Add to defaults

View file

@ -33,19 +33,28 @@ struct CustomIdleAnimation: Codable, Hashable, Equatable, Defaults.Serializable,
var source: AnimationSource var source: AnimationSource
var speed: CGFloat = 1.0 var speed: CGFloat = 1.0
var isBuiltIn: Bool = false // Track if it's bundled vs user-added var isBuiltIn: Bool = false // Track if it's bundled vs user-added
var transformConfig: AnimationTransformConfig = .default // Transform settings
init(id: UUID = UUID(), name: String, source: AnimationSource, speed: CGFloat = 1.0, isBuiltIn: Bool = false, transformConfig: AnimationTransformConfig = .default) { init(id: UUID = UUID(), name: String, source: AnimationSource, speed: CGFloat = 1.0, isBuiltIn: Bool = false) {
self.id = id self.id = id
self.name = name self.name = name
self.source = source self.source = source
self.speed = speed self.speed = speed
self.isBuiltIn = isBuiltIn self.isBuiltIn = isBuiltIn
self.transformConfig = transformConfig }
/// Get the effective transform config (override or default)
func getTransformConfig() -> AnimationTransformConfig {
let override = Defaults[.animationTransformOverrides][id.uuidString]
if let override = override {
print("📋 [CustomIdleAnimation] Found override for '\(name)': \(override)")
} else {
print("📋 [CustomIdleAnimation] No override for '\(name)', using default")
}
return override ?? .default
} }
} }
struct AnimationTransformConfig: Codable, Hashable, Equatable { struct AnimationTransformConfig: Codable, Hashable, Equatable, Defaults.Serializable {
var scale: CGFloat = 1.0 var scale: CGFloat = 1.0
var offsetX: CGFloat = 0 var offsetX: CGFloat = 0
var offsetY: CGFloat = 0 var offsetY: CGFloat = 0
@ -299,6 +308,7 @@ extension Defaults.Keys {
static let showNotHumanFace = Key<Bool>("showNotHumanFace", default: false) static let showNotHumanFace = Key<Bool>("showNotHumanFace", default: false)
static let customIdleAnimations = Key<[CustomIdleAnimation]>("customIdleAnimations", default: []) static let customIdleAnimations = Key<[CustomIdleAnimation]>("customIdleAnimations", default: [])
static let selectedIdleAnimation = Key<CustomIdleAnimation?>("selectedIdleAnimation", default: nil) static let selectedIdleAnimation = Key<CustomIdleAnimation?>("selectedIdleAnimation", default: nil)
static let animationTransformOverrides = Key<[String: AnimationTransformConfig]>("animationTransformOverrides", default: [:])
static let tileShowLabels = Key<Bool>("tileShowLabels", default: false) static let tileShowLabels = Key<Bool>("tileShowLabels", default: false)
static let showCalendar = Key<Bool>("showCalendar", default: false) static let showCalendar = Key<Bool>("showCalendar", default: false)
static let hideCompletedReminders = Key<Bool>("hideCompletedReminders", default: true) static let hideCompletedReminders = Key<Bool>("hideCompletedReminders", default: true)