Atoll/DynamicIsland/managers/IdleAnimationManager.swift
Hariharan Mudaliar 56cb69d856 Messed up some stuff
2025-10-11 14:20:23 +05:30

301 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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)
}
// Get existing animations
var existing = Defaults[.customIdleAnimations]
if existing.isEmpty {
// First launch - set everything
Defaults[.customIdleAnimations] = animations
Defaults[.selectedIdleAnimation] = builtInFace
print("✅ [IdleAnimationManager] First launch: Initialized with \(animations.count) animations")
} else {
// Subsequent launch - ensure all bundled animations are present
let existingIDs = Set(existing.map { $0.id })
let existingNames = Set(existing.filter { $0.isBuiltIn }.map { $0.name })
// Add any missing bundled animations
for bundledAnim in animations where bundledAnim.isBuiltIn {
if !existingNames.contains(bundledAnim.name) {
existing.insert(bundledAnim, at: existing.firstIndex(where: { !$0.isBuiltIn }) ?? existing.count)
print("➕ [IdleAnimationManager] Added missing bundled animation: \(bundledAnim.name)")
}
}
Defaults[.customIdleAnimations] = existing
print("✅ [IdleAnimationManager] Subsequent launch: \(existing.count) total animations (\(animations.count - 1) bundled + built-in face)")
}
}
// 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<CustomIdleAnimation, Error> {
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<CustomIdleAnimation, Error> {
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<CustomIdleAnimation, Error> {
// 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"
}
}
}