// // IdleAnimationManager.swift // DynamicIsland // // Created by AI Assistant on 11/10/2025. // Manages custom idle animations (Lottie files) for the Dynamic Island // import Foundation import Defaults import AppKit class IdleAnimationManager { static let shared = IdleAnimationManager() // Storage directory for user-imported animations private let storageDirectory: URL private init() { // Create storage directory in Application Support let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! storageDirectory = appSupport.appendingPathComponent("DynamicIsland/IdleAnimations", isDirectory: true) // Create directory if it doesn't exist try? FileManager.default.createDirectory(at: storageDirectory, withIntermediateDirectories: true) print("📁 [IdleAnimationManager] Storage directory: \(storageDirectory.path)") } // MARK: - Initialization /// Load bundled animations from the LottieAnimations folder and built-in face func initializeDefaultAnimations() { var animations: [CustomIdleAnimation] = [] // Add built-in face as first option let builtInFace = CustomIdleAnimation( name: "Classic Face", source: .builtInFace, speed: 1.0, isBuiltIn: true ) animations.append(builtInFace) // Load bundled Lottie files if let bundledAnimations = loadBundledAnimations() { animations.append(contentsOf: bundledAnimations) } // Load previously imported user animations from storage if let userAnimations = loadStoredUserAnimations() { animations.append(contentsOf: userAnimations) } // Only set if empty (first launch) if Defaults[.customIdleAnimations].isEmpty { Defaults[.customIdleAnimations] = animations Defaults[.selectedIdleAnimation] = builtInFace // Default to built-in face print("✅ [IdleAnimationManager] Initialized with \(animations.count) animations") } else { // Merge: add any new bundled animations that aren't already in the list var existing = Defaults[.customIdleAnimations] let existingNames = Set(existing.map { $0.name }) for newAnim in animations where newAnim.isBuiltIn { if !existingNames.contains(newAnim.name) { existing.append(newAnim) } } Defaults[.customIdleAnimations] = existing print("✅ [IdleAnimationManager] Updated with \(existing.count) animations") } } // MARK: - Bundled Animations /// Load animations from the LottieAnimations folder in the bundle private func loadBundledAnimations() -> [CustomIdleAnimation]? { guard let bundlePath = Bundle.main.resourcePath else { print("⚠️ [IdleAnimationManager] Could not find bundle resource path") return nil } let lottieFolder = URL(fileURLWithPath: bundlePath).appendingPathComponent("LottieAnimations") guard FileManager.default.fileExists(atPath: lottieFolder.path) else { print("⚠️ [IdleAnimationManager] LottieAnimations folder not found at: \(lottieFolder.path)") return nil } 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), speed: 1.0, isBuiltIn: true ) } print("📦 [IdleAnimationManager] Loaded \(animations.count) bundled animations") return animations } catch { print("❌ [IdleAnimationManager] Error loading bundled animations: \(error)") return nil } } // MARK: - User Animations /// Load user-imported animations from storage directory private func loadStoredUserAnimations() -> [CustomIdleAnimation]? { do { let files = try FileManager.default.contentsOfDirectory(at: storageDirectory, 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), speed: 1.0, isBuiltIn: false ) } if !animations.isEmpty { print("💾 [IdleAnimationManager] Loaded \(animations.count) stored user animations") } return animations.isEmpty ? nil : animations } catch { print("❌ [IdleAnimationManager] Error loading stored animations: \(error)") return nil } } // MARK: - Import & Export /// 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 { let fileName = name ?? url.deletingPathExtension().lastPathComponent // If it's a remote URL, download it first if url.scheme == "http" || url.scheme == "https" { return importRemoteAnimation(from: url, name: fileName, speed: speed, transformConfig: transformConfig) } // Local file import return importLocalFile(from: url, name: fileName, speed: speed, transformConfig: transformConfig) } /// Import a local Lottie JSON file private func importLocalFile(from sourceURL: URL, name: String, speed: CGFloat, transformConfig: AnimationTransformConfig) -> Result { do { // Validate it's a JSON file guard sourceURL.pathExtension.lowercased() == "json" else { return .failure(AnimationImportError.invalidFileType) } // Validate JSON content (basic check) let data = try Data(contentsOf: sourceURL) guard let _ = try? JSONSerialization.jsonObject(with: data) else { return .failure(AnimationImportError.invalidJSON) } // Generate unique filename let uniqueFileName = "\(UUID().uuidString).json" let destinationURL = storageDirectory.appendingPathComponent(uniqueFileName) // Copy file to storage try FileManager.default.copyItem(at: sourceURL, to: destinationURL) // Create animation object let animation = CustomIdleAnimation( name: name, source: .lottieFile(destinationURL), speed: speed, isBuiltIn: false, transformConfig: transformConfig ) // Add to defaults var animations = Defaults[.customIdleAnimations] animations.append(animation) Defaults[.customIdleAnimations] = animations print("✅ [IdleAnimationManager] Imported local file: \(name)") return .success(animation) } catch { print("❌ [IdleAnimationManager] Import failed: \(error)") return .failure(error) } } /// Import animation from remote URL private func importRemoteAnimation(from url: URL, name: String, speed: CGFloat, transformConfig: AnimationTransformConfig) -> Result { // For remote URLs, we store the URL directly (no download) // The LottieView will handle downloading when needed let animation = CustomIdleAnimation( name: name, source: .lottieURL(url), speed: speed, isBuiltIn: false, transformConfig: transformConfig ) // Add to defaults var animations = Defaults[.customIdleAnimations] animations.append(animation) Defaults[.customIdleAnimations] = animations print("✅ [IdleAnimationManager] Added remote animation: \(name)") return .success(animation) } // MARK: - Management /// Delete an animation (only user-added ones, not built-in) func deleteAnimation(_ animation: CustomIdleAnimation) -> Bool { guard !animation.isBuiltIn else { print("⚠️ [IdleAnimationManager] Cannot delete built-in animation") return false } // Remove from defaults var animations = Defaults[.customIdleAnimations] guard let index = animations.firstIndex(of: animation) else { return false } animations.remove(at: index) Defaults[.customIdleAnimations] = animations // If it's a local file, delete it from storage if case .lottieFile(let url) = animation.source { // Only delete if it's in our storage directory (not bundled) if url.path.contains(storageDirectory.path) { try? FileManager.default.removeItem(at: url) print("🗑️ [IdleAnimationManager] Deleted file: \(url.lastPathComponent)") } } // If deleted animation was selected, select the first one if Defaults[.selectedIdleAnimation] == animation { Defaults[.selectedIdleAnimation] = animations.first } print("✅ [IdleAnimationManager] Deleted animation: \(animation.name)") return true } /// Update animation properties func updateAnimation(_ animation: CustomIdleAnimation, name: String? = nil, speed: CGFloat? = nil) { var animations = Defaults[.customIdleAnimations] guard let index = animations.firstIndex(where: { $0.id == animation.id }) else { return } if let name = name { animations[index].name = name } if let speed = speed { animations[index].speed = speed } Defaults[.customIdleAnimations] = animations // Update selected animation if it's the same one if Defaults[.selectedIdleAnimation]?.id == animation.id { Defaults[.selectedIdleAnimation] = animations[index] } print("✅ [IdleAnimationManager] Updated animation: \(animation.name)") } } // MARK: - Error Types enum AnimationImportError: LocalizedError { case invalidFileType case invalidJSON case downloadFailed var errorDescription: String? { switch self { case .invalidFileType: return "Only .json files are supported" case .invalidJSON: return "Invalid Lottie JSON format" case .downloadFailed: return "Failed to download animation" } } }