mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
301 lines
12 KiB
Swift
301 lines
12 KiB
Swift
//
|
|
// 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<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"
|
|
}
|
|
}
|
|
}
|