mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #35 from Ebullioscopic/feature/liveactivity
Feature/liveactivity
This commit is contained in:
commit
af90430bcb
17 changed files with 1742 additions and 549 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -82,3 +82,4 @@ PrivateWorks
|
|||
*.sh
|
||||
comparison_report.json
|
||||
*.txt
|
||||
ANIMATION_DEBUG_GUIDE.md
|
||||
|
|
@ -21,6 +21,9 @@
|
|||
7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 146BC4362D366E9B0047C66A /* MacroVisionKit */; };
|
||||
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */; };
|
||||
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */; };
|
||||
7E8840D72E9A346700C5CB8C /* Dog waiting.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D62E9A346700C5CB8C /* Dog waiting.json */; };
|
||||
7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D82E9A347000C5CB8C /* Moody Dog.json */; };
|
||||
7E8840DB2E9A347800C5CB8C /* Orange Cat Peeping.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */; };
|
||||
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; };
|
||||
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; };
|
||||
BA90505C953246D0AB206451 /* MediaRemoteAdapter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; };
|
||||
|
|
@ -47,6 +50,9 @@
|
|||
7E25ECA42E6C6E7800700E72 /* DisplayServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DisplayServices.framework; path = /System/Library/PrivateFrameworks/DisplayServices.framework; sourceTree = "<absolute>"; };
|
||||
7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */ = {isa = PBXFileReference; lastKnownFileType = text.script.perl; path = "mediaremote-adapter.pl"; sourceTree = "<group>"; };
|
||||
7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = NowPlayingTestClient; sourceTree = "<group>"; };
|
||||
7E8840D62E9A346700C5CB8C /* Dog waiting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Dog waiting.json"; sourceTree = "<group>"; };
|
||||
7E8840D82E9A347000C5CB8C /* Moody Dog.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Moody Dog.json"; sourceTree = "<group>"; };
|
||||
7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Orange Cat Peeping.json"; sourceTree = "<group>"; };
|
||||
7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; };
|
||||
7EE1D73E2E6B1E1200997219 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; };
|
||||
F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; };
|
||||
|
|
@ -109,9 +115,20 @@
|
|||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7E8840D52E9A341F00C5CB8C /* LottieAnimations */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7E8840D62E9A346700C5CB8C /* Dog waiting.json */,
|
||||
7E8840D82E9A347000C5CB8C /* Moody Dog.json */,
|
||||
7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */,
|
||||
);
|
||||
path = LottieAnimations;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F54406DE2CD4987A00C7AFD9 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7E8840D52E9A341F00C5CB8C /* LottieAnimations */,
|
||||
7E81304F2E3FB42700E08F3F /* Frameworks */,
|
||||
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
|
||||
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
|
||||
|
|
@ -215,6 +232,9 @@
|
|||
files = (
|
||||
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */,
|
||||
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */,
|
||||
7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */,
|
||||
7E8840D72E9A346700C5CB8C /* Dog waiting.json in Resources */,
|
||||
7E8840DB2E9A347800C5CB8C /* Orange Cat Peeping.json in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -516,7 +516,7 @@ struct ContentView: View {
|
|||
Rectangle()
|
||||
.fill(.black)
|
||||
.frame(width: vm.closedNotchSize.width - 20)
|
||||
MinimalFaceFeatures()
|
||||
IdleAnimationView()
|
||||
}
|
||||
}.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
let webcamManager = WebcamManager.shared
|
||||
let dndManager = DoNotDisturbManager.shared // NEW: DND detection
|
||||
let bluetoothAudioManager = BluetoothAudioManager.shared // NEW: Bluetooth audio detection
|
||||
let idleAnimationManager = IdleAnimationManager.shared // NEW: Custom idle animations
|
||||
var closeNotchWorkItem: DispatchWorkItem?
|
||||
private var previousScreens: [NSScreen]?
|
||||
private var onboardingWindowController: NSWindowController?
|
||||
|
|
@ -402,6 +403,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
// Migrate legacy progress bar settings
|
||||
Defaults.Keys.migrateProgressBarStyle()
|
||||
|
||||
// Initialize idle animations (load bundled + built-in face)
|
||||
idleAnimationManager.initializeDefaultAnimations()
|
||||
|
||||
// Setup SystemHUD Manager
|
||||
SystemHUDManager.shared.setup(coordinator: coordinator)
|
||||
|
||||
|
|
|
|||
|
|
@ -810,6 +810,10 @@
|
|||
},
|
||||
"%lld" : {
|
||||
|
||||
},
|
||||
"%lld animations" : {
|
||||
"comment" : "A label displaying the number of custom animations available. The argument is the count of custom animations.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"%lld colors" : {
|
||||
|
||||
|
|
@ -917,6 +921,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lldx" : {
|
||||
|
||||
},
|
||||
"•" : {
|
||||
"comment" : "A period used to separate two related items.",
|
||||
|
|
@ -1233,6 +1240,9 @@
|
|||
},
|
||||
"+%lld more" : {
|
||||
|
||||
},
|
||||
"1x" : {
|
||||
|
||||
},
|
||||
"3 items" : {
|
||||
|
||||
|
|
@ -1254,9 +1264,18 @@
|
|||
},
|
||||
"15 colors" : {
|
||||
|
||||
},
|
||||
"20" : {
|
||||
|
||||
},
|
||||
"20 colors" : {
|
||||
|
||||
},
|
||||
"30" : {
|
||||
|
||||
},
|
||||
"100%" : {
|
||||
|
||||
},
|
||||
"About" : {
|
||||
"localizations" : {
|
||||
|
|
@ -1678,8 +1697,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Add Animation from URL" : {
|
||||
"comment" : "A title for the sheet that allows users to add animations from a URL.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Add files" : {
|
||||
|
||||
},
|
||||
"Add from URL..." : {
|
||||
"comment" : "A button label that prompts the user to add a Lottie animation from a URL.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Add manually" : {
|
||||
"localizations" : {
|
||||
|
|
@ -1781,6 +1808,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Add New" : {
|
||||
"comment" : "A label displayed below the button to add a new idle animation.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Add new visualizer" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
@ -2596,6 +2627,13 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Animation" : {
|
||||
|
||||
},
|
||||
"Animation Name" : {
|
||||
"comment" : "A label for the name input field in the URL import sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"API Configuration" : {
|
||||
|
||||
},
|
||||
|
|
@ -2802,6 +2840,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Are you sure you want to delete \"%@\"?" : {
|
||||
"comment" : "A confirmation prompt asking the user to confirm the deletion of a custom idle animation. The placeholder text inside the quotation marks should be replaced with the name of the animation.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Are you sure you want to delete this color from your history?" : {
|
||||
|
||||
},
|
||||
|
|
@ -3410,6 +3452,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Blue dashed box shows actual size in Dynamic Island" : {
|
||||
|
||||
},
|
||||
"Bluetooth Audio Devices" : {
|
||||
"comment" : "A section header that lists Bluetooth audio devices.",
|
||||
|
|
@ -3524,9 +3569,16 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Bottom Pad:" : {
|
||||
|
||||
},
|
||||
"Brightness HUD" : {
|
||||
|
||||
},
|
||||
"Built-in" : {
|
||||
"comment" : "A label indicating that an animation is a built-in one.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Built-in System Monitoring" : {
|
||||
|
||||
|
|
@ -3836,6 +3888,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Center" : {
|
||||
|
||||
},
|
||||
"Change" : {
|
||||
|
||||
|
|
@ -4245,6 +4300,10 @@
|
|||
},
|
||||
"Choose AI model" : {
|
||||
|
||||
},
|
||||
"Choose animation to display when Dynamic Island is idle. Tap to select, hold to delete custom animations." : {
|
||||
"comment" : "A footer label under the section that lists their idle animations.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Choose File" : {
|
||||
|
||||
|
|
@ -5188,6 +5247,9 @@
|
|||
},
|
||||
"Custom: %@" : {
|
||||
|
||||
},
|
||||
"Customize Animation" : {
|
||||
|
||||
},
|
||||
"Customize in Settings" : {
|
||||
"localizations" : {
|
||||
|
|
@ -5394,6 +5456,10 @@
|
|||
},
|
||||
"Delete" : {
|
||||
|
||||
},
|
||||
"Delete Animation" : {
|
||||
"comment" : "The title of an alert that appears when trying to delete a custom animation.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Delete Color" : {
|
||||
|
||||
|
|
@ -5714,6 +5780,9 @@
|
|||
},
|
||||
"Display Mode" : {
|
||||
|
||||
},
|
||||
"Display Options" : {
|
||||
|
||||
},
|
||||
"Displays a HUD notification when Bluetooth audio devices (headphones, AirPods, speakers) connect, showing device name and battery level." : {
|
||||
"comment" : "A footer for the \"Bluetooth Audio Devices\" section in the HUD settings.",
|
||||
|
|
@ -6150,6 +6219,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Edit Animation" : {
|
||||
|
||||
},
|
||||
"Edit layout" : {
|
||||
"localizations" : {
|
||||
|
|
@ -6253,6 +6325,10 @@
|
|||
},
|
||||
"Email address" : {
|
||||
|
||||
},
|
||||
"Enable \"Show cool face animation while inactivity\" to customize animations" : {
|
||||
"comment" : "A message displayed below the section header in the IdleAnimationsSettingsSection, explaining that users can customize animations by enabling this feature.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enable Built-in System HUD" : {
|
||||
|
||||
|
|
@ -7108,6 +7184,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Expand Notch with Animation" : {
|
||||
|
||||
},
|
||||
"Extensions" : {
|
||||
"localizations" : {
|
||||
|
|
@ -7208,6 +7287,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Fill Notch" : {
|
||||
|
||||
},
|
||||
"Finish" : {
|
||||
"localizations" : {
|
||||
|
|
@ -7308,6 +7390,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Fit to Notch" : {
|
||||
|
||||
},
|
||||
"Gemini API Key" : {
|
||||
|
||||
|
|
@ -8020,6 +8105,9 @@
|
|||
},
|
||||
"Graph Visibility" : {
|
||||
|
||||
},
|
||||
"Height:" : {
|
||||
|
||||
},
|
||||
"Hide" : {
|
||||
|
||||
|
|
@ -8329,6 +8417,13 @@
|
|||
},
|
||||
"History Size" : {
|
||||
|
||||
},
|
||||
"Horizontal:" : {
|
||||
|
||||
},
|
||||
"https://example.com/animation.json" : {
|
||||
"comment" : "A placeholder text for a Lottie JSON URL input field.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"https://github.com/th-ch/youtube-music" : {
|
||||
"localizations" : {
|
||||
|
|
@ -8630,6 +8725,21 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Idle Animation Style" : {
|
||||
"comment" : "The header label for the section that lets users choose the style of idle animation.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Import" : {
|
||||
|
||||
},
|
||||
"Import Error" : {
|
||||
"comment" : "The title of an alert that appears when there is an error importing a Lottie animation.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Import File..." : {
|
||||
"comment" : "A menu option to import a Lottie animation from a file.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"In progress" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
@ -9061,6 +9171,12 @@
|
|||
},
|
||||
"Live Performance Data" : {
|
||||
|
||||
},
|
||||
"Loop Mode" : {
|
||||
|
||||
},
|
||||
"Loop Mode:" : {
|
||||
|
||||
},
|
||||
"Lottie JSON URL" : {
|
||||
"localizations" : {
|
||||
|
|
@ -11290,6 +11406,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"My Animation" : {
|
||||
"comment" : "A default name for a new animation added via a URL.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Name" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
@ -12760,6 +12880,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Opacity:" : {
|
||||
|
||||
},
|
||||
"Open Google AI Studio" : {
|
||||
|
||||
|
|
@ -12949,6 +13072,15 @@
|
|||
},
|
||||
"Popover mode shows the assistant as a dropdown attached to the AI button. Panel mode shows the assistant in a floating window near the notch." : {
|
||||
|
||||
},
|
||||
"Position" : {
|
||||
|
||||
},
|
||||
"Preview" : {
|
||||
|
||||
},
|
||||
"Preview Zoom:" : {
|
||||
|
||||
},
|
||||
"Privacy & Security" : {
|
||||
"comment" : "The title of the privacy and security settings screen.",
|
||||
|
|
@ -13070,6 +13202,9 @@
|
|||
},
|
||||
"Quick Actions" : {
|
||||
|
||||
},
|
||||
"Quick Presets" : {
|
||||
|
||||
},
|
||||
"Quit" : {
|
||||
"localizations" : {
|
||||
|
|
@ -13507,6 +13642,12 @@
|
|||
},
|
||||
"Replaces system HUD notifications with Dynamic Island displays." : {
|
||||
|
||||
},
|
||||
"Reset" : {
|
||||
|
||||
},
|
||||
"Reset All" : {
|
||||
|
||||
},
|
||||
"Reset to Default" : {
|
||||
|
||||
|
|
@ -13616,6 +13757,9 @@
|
|||
},
|
||||
"Resume" : {
|
||||
|
||||
},
|
||||
"Rotation:" : {
|
||||
|
||||
},
|
||||
"Running" : {
|
||||
"localizations" : {
|
||||
|
|
@ -13719,9 +13863,21 @@
|
|||
},
|
||||
"Save" : {
|
||||
|
||||
},
|
||||
"Save Changes" : {
|
||||
|
||||
},
|
||||
"Save Configuration" : {
|
||||
|
||||
},
|
||||
"Scale Down" : {
|
||||
|
||||
},
|
||||
"Scale Up 2x" : {
|
||||
|
||||
},
|
||||
"Scale:" : {
|
||||
|
||||
},
|
||||
"Screen Assistant" : {
|
||||
|
||||
|
|
@ -13978,6 +14134,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Selected" : {
|
||||
"comment" : "A label indicating that an animation is currently selected.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Selected animation" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
@ -14919,6 +15079,9 @@
|
|||
},
|
||||
"Shows the model's reasoning process before the final answer" : {
|
||||
|
||||
},
|
||||
"Size & Scale" : {
|
||||
|
||||
},
|
||||
"Slider color" : {
|
||||
"localizations" : {
|
||||
|
|
@ -15427,6 +15590,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Speed:" : {
|
||||
|
||||
},
|
||||
"Square" : {
|
||||
"localizations" : {
|
||||
|
|
@ -16222,6 +16388,9 @@
|
|||
"Top Processes by %@" : {
|
||||
"comment" : "A heading that announces the top processes by a specific metric. The text inside the parentheses is replaced with the actual metric name.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Transform" : {
|
||||
|
||||
},
|
||||
"Transform your notch truly yours" : {
|
||||
|
||||
|
|
@ -17039,6 +17208,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vertical:" : {
|
||||
|
||||
},
|
||||
"View on GitHub: th-ch/youtube-music" : {
|
||||
"localizations" : {
|
||||
|
|
@ -17345,9 +17517,15 @@
|
|||
},
|
||||
"When disabled, all keyboard shortcuts will be inactive. You can still use the UI controls." : {
|
||||
|
||||
},
|
||||
"When enabled, the Dynamic Island will expand horizontally to match the animation size" : {
|
||||
|
||||
},
|
||||
"When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : {
|
||||
|
||||
},
|
||||
"Width:" : {
|
||||
|
||||
},
|
||||
"With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : {
|
||||
|
||||
|
|
|
|||
103
DynamicIsland/components/IdleAnimationView.swift
Normal file
103
DynamicIsland/components/IdleAnimationView.swift
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
//
|
||||
// IdleAnimationView.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by AI Assistant on 11/10/2025.
|
||||
// Unified view for displaying idle animations (Lottie or built-in face)
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Lottie
|
||||
import LottieUI
|
||||
import Defaults
|
||||
|
||||
struct IdleAnimationView: View {
|
||||
@Default(.selectedIdleAnimation) var selectedAnimation
|
||||
@Default(.animationTransformOverrides) var overrides
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let animation = selectedAnimation {
|
||||
AnimationContentView(animation: animation)
|
||||
.id("\(animation.id)-\(overrides[animation.id.uuidString]?.hashValue ?? 0)") // Force recreation when override changes
|
||||
} else {
|
||||
// Fallback to original face if nothing selected
|
||||
MinimalFaceFeatures(height: 20, width: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal view that renders the actual animation content
|
||||
private struct AnimationContentView: View {
|
||||
let animation: CustomIdleAnimation
|
||||
|
||||
var body: some View {
|
||||
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 {
|
||||
case .lottieFile(let url):
|
||||
LottieView(state: LUStateData(
|
||||
type: .loadedFrom(url),
|
||||
speed: animation.speed,
|
||||
loopMode: config.loopMode.lottieLoopMode
|
||||
))
|
||||
.id(animation.id) // Force reload when animation changes
|
||||
.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()
|
||||
|
||||
case .lottieURL(let url):
|
||||
LottieView(state: LUStateData(
|
||||
type: .loadedFrom(url),
|
||||
speed: animation.speed,
|
||||
loopMode: config.loopMode.lottieLoopMode
|
||||
))
|
||||
.id(animation.id) // Force reload when animation changes
|
||||
.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()
|
||||
|
||||
case .builtInFace:
|
||||
// Apply transforms to built-in face too!
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview {
|
||||
ZStack {
|
||||
Color.black
|
||||
IdleAnimationView()
|
||||
}
|
||||
.frame(width: 100, height: 50)
|
||||
}
|
||||
544
DynamicIsland/components/Settings/AnimationEditorView.swift
Normal file
544
DynamicIsland/components/Settings/AnimationEditorView.swift
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
//
|
||||
// AnimationEditorView.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by AI Assistant on 11/10/2025.
|
||||
// Editor for resizing, repositioning, and cropping Lottie animations
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import LottieUI
|
||||
import Defaults
|
||||
|
||||
struct AnimationEditorView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
let sourceURL: URL
|
||||
let isRemoteURL: Bool
|
||||
@Binding var animation: CustomIdleAnimation?
|
||||
let existingAnimation: CustomIdleAnimation?
|
||||
|
||||
@State private var name: String
|
||||
@State private var speed: CGFloat = 1.0
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var offsetX: CGFloat = 0
|
||||
@State private var offsetY: CGFloat = 0
|
||||
@State private var cropWidth: CGFloat = 30
|
||||
@State private var cropHeight: CGFloat = 20
|
||||
@State private var rotation: CGFloat = 0
|
||||
@State private var opacity: CGFloat = 1.0
|
||||
@State private var paddingBottom: CGFloat = 0
|
||||
@State private var expandWithAnimation: Bool = false
|
||||
@State private var loopMode: AnimationLoopMode = .loop
|
||||
|
||||
@State private var showingError = false
|
||||
@State private var errorMessage = ""
|
||||
@State private var isImporting = false
|
||||
|
||||
// Preview state
|
||||
@State private var previewScale: CGFloat = 1.0
|
||||
|
||||
init(sourceURL: URL, isRemoteURL: Bool, animation: Binding<CustomIdleAnimation?>, existingAnimation: CustomIdleAnimation? = nil) {
|
||||
self.sourceURL = sourceURL
|
||||
self.isRemoteURL = isRemoteURL
|
||||
self._animation = animation
|
||||
self.existingAnimation = existingAnimation
|
||||
|
||||
// Initialize from existing animation if editing, otherwise from URL
|
||||
if let existing = existingAnimation {
|
||||
_name = State(initialValue: existing.name)
|
||||
_speed = State(initialValue: existing.speed)
|
||||
|
||||
// Load from overrides if they exist
|
||||
let config = Defaults[.animationTransformOverrides][existing.id.uuidString] ?? .default
|
||||
_scale = State(initialValue: config.scale)
|
||||
_offsetX = State(initialValue: config.offsetX)
|
||||
_offsetY = State(initialValue: config.offsetY)
|
||||
_cropWidth = State(initialValue: config.cropWidth)
|
||||
_cropHeight = State(initialValue: config.cropHeight)
|
||||
_rotation = State(initialValue: config.rotation)
|
||||
_opacity = State(initialValue: config.opacity)
|
||||
_paddingBottom = State(initialValue: config.paddingBottom)
|
||||
_expandWithAnimation = State(initialValue: config.expandWithAnimation)
|
||||
_loopMode = State(initialValue: config.loopMode)
|
||||
} else {
|
||||
let fileName = sourceURL.deletingPathExtension().lastPathComponent
|
||||
_name = State(initialValue: fileName)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text(existingAnimation != nil ? "Edit Animation" : "Customize Animation")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
.padding()
|
||||
|
||||
Divider()
|
||||
|
||||
HSplitView {
|
||||
// Preview Panel
|
||||
VStack {
|
||||
Text("Preview")
|
||||
.font(.headline)
|
||||
.padding(.top)
|
||||
|
||||
ZStack {
|
||||
// Background to show bounds
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color.gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
|
||||
// Target size indicator (30x20 - actual size in notch)
|
||||
Rectangle()
|
||||
.strokeBorder(Color.blue.opacity(0.5), style: StrokeStyle(lineWidth: 2, dash: [5, 3]))
|
||||
.frame(width: 30 * previewScale, height: 20 * previewScale)
|
||||
|
||||
// Animation preview with transformations
|
||||
if let existing = existingAnimation, case .builtInFace = existing.source {
|
||||
// Show built-in face
|
||||
MinimalFaceFeatures(height: 20, width: 30)
|
||||
.scaleEffect(previewScale)
|
||||
.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()
|
||||
} 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)
|
||||
.padding()
|
||||
|
||||
// Preview Scale Control
|
||||
HStack {
|
||||
Text("Preview Zoom:")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Slider(value: $previewScale, in: 1...10)
|
||||
.frame(width: 150)
|
||||
Text("\(Int(previewScale))x")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 30)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.frame(minWidth: 300)
|
||||
|
||||
Divider()
|
||||
|
||||
// Controls Panel
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Name
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Animation Name")
|
||||
.font(.headline)
|
||||
TextField("Name", text: $name)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Size Controls
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Size & Scale")
|
||||
.font(.headline)
|
||||
|
||||
// Scale
|
||||
HStack {
|
||||
Text("Scale:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $scale, in: 0.1...5.0)
|
||||
Text(String(format: "%.2f", scale))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Reset") {
|
||||
withAnimation { scale = 1.0 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
// Crop Width
|
||||
HStack {
|
||||
Text("Width:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $cropWidth, in: 5...100)
|
||||
Text(String(format: "%.0f", cropWidth))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("30") {
|
||||
withAnimation { cropWidth = 30 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
// Crop Height
|
||||
HStack {
|
||||
Text("Height:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $cropHeight, in: 5...100)
|
||||
Text(String(format: "%.0f", cropHeight))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("20") {
|
||||
withAnimation { cropHeight = 20 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Position Controls
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Position")
|
||||
.font(.headline)
|
||||
|
||||
// Offset X
|
||||
HStack {
|
||||
Text("Horizontal:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $offsetX, in: -50...50)
|
||||
Text(String(format: "%.1f", offsetX))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Center") {
|
||||
withAnimation { offsetX = 0 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
// Offset Y
|
||||
HStack {
|
||||
Text("Vertical:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $offsetY, in: -50...50)
|
||||
Text(String(format: "%.1f", offsetY))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Center") {
|
||||
withAnimation { offsetY = 0 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
// Padding Bottom
|
||||
HStack {
|
||||
Text("Bottom Pad:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $paddingBottom, in: -20...20)
|
||||
Text(String(format: "%.1f", paddingBottom))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Reset") {
|
||||
withAnimation { paddingBottom = 0 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Rotation & Opacity
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Transform")
|
||||
.font(.headline)
|
||||
|
||||
// Rotation
|
||||
HStack {
|
||||
Text("Rotation:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $rotation, in: -180...180)
|
||||
Text(String(format: "%.0f°", rotation))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Reset") {
|
||||
withAnimation { rotation = 0 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
// Opacity
|
||||
HStack {
|
||||
Text("Opacity:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $opacity, in: 0...1)
|
||||
Text(String(format: "%.0f%%", opacity * 100))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("100%") {
|
||||
withAnimation { opacity = 1.0 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Animation Speed
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Animation")
|
||||
.font(.headline)
|
||||
|
||||
HStack {
|
||||
Text("Speed:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $speed, in: 0.1...3.0)
|
||||
Text(String(format: "%.2fx", speed))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("1x") {
|
||||
withAnimation { speed = 1.0 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
// Loop Mode
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Loop Mode:")
|
||||
.font(.subheadline)
|
||||
Picker("Loop Mode", selection: $loopMode) {
|
||||
ForEach(AnimationLoopMode.allCases, id: \.self) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Display Options
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Display Options")
|
||||
.font(.headline)
|
||||
|
||||
Toggle(isOn: $expandWithAnimation) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Expand Notch with Animation")
|
||||
.font(.body)
|
||||
Text("When enabled, the Dynamic Island will expand horizontally to match the animation size")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Presets
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Quick Presets")
|
||||
.font(.headline)
|
||||
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
||||
Button("Fit to Notch") {
|
||||
withAnimation {
|
||||
scale = 1.0
|
||||
cropWidth = 30
|
||||
cropHeight = 20
|
||||
offsetX = 0
|
||||
offsetY = 0
|
||||
rotation = 0
|
||||
paddingBottom = 0
|
||||
expandWithAnimation = false
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Fill Notch") {
|
||||
withAnimation {
|
||||
scale = 1.5
|
||||
cropWidth = 30
|
||||
cropHeight = 20
|
||||
offsetX = 0
|
||||
offsetY = 0
|
||||
paddingBottom = 0
|
||||
expandWithAnimation = false
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Scale Up 2x") {
|
||||
withAnimation {
|
||||
scale = 2.0
|
||||
expandWithAnimation = true
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Scale Down") {
|
||||
withAnimation {
|
||||
scale = 0.75
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Reset All") {
|
||||
withAnimation {
|
||||
scale = 1.0
|
||||
cropWidth = 30
|
||||
cropHeight = 20
|
||||
offsetX = 0
|
||||
offsetY = 0
|
||||
rotation = 0
|
||||
opacity = 1.0
|
||||
speed = 1.0
|
||||
paddingBottom = 0
|
||||
expandWithAnimation = false
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(minWidth: 350, maxWidth: 450)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Footer with actions
|
||||
HStack {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Blue dashed box shows actual size in Dynamic Island")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(existingAnimation != nil ? "Save Changes" : "Import") {
|
||||
importAnimation()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(name.isEmpty || isImporting)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(idealWidth: 900, idealHeight: 650)
|
||||
.frame(minWidth: 800, minHeight: 600)
|
||||
.fixedSize()
|
||||
.alert("Import Error", isPresented: $showingError) {
|
||||
Button("OK") { }
|
||||
} message: {
|
||||
Text(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Import Logic
|
||||
|
||||
private func importAnimation() {
|
||||
guard !name.isEmpty else { return }
|
||||
|
||||
isImporting = true
|
||||
|
||||
// Create animation config with transform settings
|
||||
let config = AnimationTransformConfig(
|
||||
scale: scale,
|
||||
offsetX: offsetX,
|
||||
offsetY: offsetY,
|
||||
cropWidth: cropWidth,
|
||||
cropHeight: cropHeight,
|
||||
rotation: rotation,
|
||||
opacity: opacity,
|
||||
paddingBottom: paddingBottom,
|
||||
expandWithAnimation: expandWithAnimation,
|
||||
loopMode: loopMode
|
||||
)
|
||||
|
||||
// If editing existing animation, save transform config to overrides
|
||||
if let existing = existingAnimation {
|
||||
// Store only the transform config override
|
||||
var overrides = Defaults[.animationTransformOverrides]
|
||||
overrides[existing.id.uuidString] = config
|
||||
Defaults[.animationTransformOverrides] = overrides
|
||||
|
||||
print("✅ [AnimationEditor] Saved transform override for: \(existing.name)")
|
||||
print("✅ [AnimationEditor] Override: \(config)")
|
||||
|
||||
// Force view refresh by updating selectedIdleAnimation if this is the selected one
|
||||
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()
|
||||
return
|
||||
}
|
||||
|
||||
// Import with config (new animation)
|
||||
let result = IdleAnimationManager.shared.importLottieFile(
|
||||
from: sourceURL,
|
||||
name: name,
|
||||
speed: speed
|
||||
)
|
||||
|
||||
switch result {
|
||||
case .success(let importedAnimation):
|
||||
animation = importedAnimation
|
||||
dismiss()
|
||||
|
||||
case .failure(let error):
|
||||
errorMessage = error.localizedDescription
|
||||
showingError = true
|
||||
isImporting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview {
|
||||
AnimationEditorView(
|
||||
sourceURL: URL(string: "https://assets9.lottiefiles.com/packages/lf20_mniampqn.json")!,
|
||||
isRemoteURL: true,
|
||||
animation: .constant(nil)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,509 @@
|
|||
//
|
||||
// IdleAnimationsSettingsSection.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by AI Assistant on 11/10/2025.
|
||||
// Settings section for custom idle animations
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
import LottieUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - Editor State for Sheet Presentation
|
||||
private struct EditorState: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL?
|
||||
let isRemote: Bool
|
||||
let existingAnimation: CustomIdleAnimation?
|
||||
}
|
||||
|
||||
struct IdleAnimationsSettingsSection: View {
|
||||
@Default(.customIdleAnimations) var customIdleAnimations
|
||||
@Default(.selectedIdleAnimation) var selectedIdleAnimation
|
||||
@Default(.showNotHumanFace) var showNotHumanFace
|
||||
|
||||
@State private var showingFilePicker = false
|
||||
@State private var showingURLSheet = false
|
||||
@State private var urlInput = ""
|
||||
@State private var nameInput = ""
|
||||
@State private var selectedForDeletion: CustomIdleAnimation?
|
||||
@State private var showingDeleteAlert = false
|
||||
@State private var importError: String?
|
||||
@State private var showingError = false
|
||||
|
||||
// Animation editor state
|
||||
@State private var showingEditor = false
|
||||
@State private var editorSourceURL: URL?
|
||||
@State private var editorIsRemote = false
|
||||
@State private var editedAnimation: CustomIdleAnimation?
|
||||
@State private var editingExistingAnimation: CustomIdleAnimation?
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
if showNotHumanFace {
|
||||
// Horizontal scrollable grid of animations
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack(spacing: 16) {
|
||||
ForEach(customIdleAnimations) { animation in
|
||||
AnimationPreviewCard(
|
||||
animation: animation,
|
||||
isSelected: selectedIdleAnimation?.id == animation.id,
|
||||
onSelect: {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
selectedIdleAnimation = animation
|
||||
}
|
||||
},
|
||||
onDelete: animation.isBuiltIn ? nil : {
|
||||
selectedForDeletion = animation
|
||||
showingDeleteAlert = true
|
||||
},
|
||||
onEdit: {
|
||||
print("🔧 [Edit] Attempting to edit animation: \(animation.name)")
|
||||
print("🔧 [Edit] Animation source: \(animation.source)")
|
||||
|
||||
// Set state immediately
|
||||
editingExistingAnimation = animation
|
||||
|
||||
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
|
||||
editorIsRemote = false
|
||||
|
||||
case .lottieURL(let url):
|
||||
print("🔧 [Edit] Lottie URL: \(url)")
|
||||
editorSourceURL = url
|
||||
editorIsRemote = 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
|
||||
}
|
||||
|
||||
// Show editor with slight delay to ensure state is set
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
showingEditor = true
|
||||
print("🔧 [Edit] Sheet should now be visible")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Add animation button
|
||||
AddAnimationCard {
|
||||
showingFilePicker = true
|
||||
} onAddURL: {
|
||||
urlInput = ""
|
||||
nameInput = ""
|
||||
showingURLSheet = true
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.frame(height: 140)
|
||||
} else {
|
||||
Text("Enable \"Show cool face animation while inactivity\" to customize animations")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Idle Animation Style")
|
||||
Spacer()
|
||||
if showNotHumanFace {
|
||||
Text("\(customIdleAnimations.count) animations")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
if showNotHumanFace {
|
||||
Text("Choose animation to display when Dynamic Island is idle. Tap to select, hold to delete custom animations.")
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showingFilePicker,
|
||||
allowedContentTypes: [UTType.json],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
handleFileImport(result)
|
||||
}
|
||||
.sheet(isPresented: $showingURLSheet) {
|
||||
URLImportSheet(
|
||||
urlInput: $urlInput,
|
||||
nameInput: $nameInput,
|
||||
onImport: { handleURLImport() },
|
||||
onCancel: { showingURLSheet = false }
|
||||
)
|
||||
}
|
||||
.sheet(item: Binding(
|
||||
get: { showingEditor ? EditorState(url: editorSourceURL, isRemote: editorIsRemote, existingAnimation: editingExistingAnimation) : nil },
|
||||
set: { newValue in
|
||||
showingEditor = (newValue != nil)
|
||||
if newValue == nil {
|
||||
// Clean up when sheet dismisses
|
||||
editorSourceURL = nil
|
||||
editingExistingAnimation = nil
|
||||
}
|
||||
}
|
||||
)) { state in
|
||||
AnimationEditorView(
|
||||
sourceURL: state.url ?? URL(string: "builtin://face")!,
|
||||
isRemoteURL: state.isRemote,
|
||||
animation: $editedAnimation,
|
||||
existingAnimation: state.existingAnimation
|
||||
)
|
||||
.interactiveDismissDisabled(false)
|
||||
.onChange(of: editedAnimation) { oldValue, newValue in
|
||||
if let animation = newValue {
|
||||
// Animation was successfully imported/edited via editor
|
||||
if let existingAnim = editingExistingAnimation {
|
||||
// Editing existing animation - update it
|
||||
var animations = Defaults[.customIdleAnimations]
|
||||
if let index = animations.firstIndex(where: { $0.id == existingAnim.id }) {
|
||||
animations[index] = animation
|
||||
Defaults[.customIdleAnimations] = animations
|
||||
|
||||
// Update selection if this was the selected animation
|
||||
if selectedIdleAnimation?.id == existingAnim.id {
|
||||
Defaults[.selectedIdleAnimation] = animation
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// New import - just select it
|
||||
withAnimation {
|
||||
selectedIdleAnimation = animation
|
||||
}
|
||||
}
|
||||
showingEditor = false
|
||||
// Reset editor state
|
||||
editorSourceURL = nil
|
||||
editedAnimation = nil
|
||||
editingExistingAnimation = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Delete Animation", isPresented: $showingDeleteAlert) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Delete", role: .destructive) {
|
||||
if let animation = selectedForDeletion {
|
||||
IdleAnimationManager.shared.deleteAnimation(animation)
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
if let animation = selectedForDeletion {
|
||||
Text("Are you sure you want to delete \"\(animation.name)\"?")
|
||||
}
|
||||
}
|
||||
.alert("Import Error", isPresented: $showingError) {
|
||||
Button("OK") { }
|
||||
} message: {
|
||||
Text(importError ?? "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Import Handlers
|
||||
|
||||
private func handleFileImport(_ result: Result<[URL], Error>) {
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
guard let url = urls.first else { return }
|
||||
|
||||
// Show editor instead of directly importing
|
||||
editorSourceURL = url
|
||||
editorIsRemote = false
|
||||
editedAnimation = nil
|
||||
editingExistingAnimation = nil // Reset to nil for new imports
|
||||
|
||||
// Show editor with slight delay to ensure state is set
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
showingEditor = true
|
||||
print("📥 [Import] Opening editor for new import")
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
importError = error.localizedDescription
|
||||
showingError = true
|
||||
}
|
||||
}
|
||||
|
||||
private func handleURLImport() {
|
||||
guard let url = URL(string: urlInput), !nameInput.isEmpty else {
|
||||
importError = "Please provide both a valid URL and name"
|
||||
showingError = true
|
||||
return
|
||||
}
|
||||
|
||||
// Show editor instead of directly importing
|
||||
editorSourceURL = url
|
||||
editorIsRemote = true
|
||||
editedAnimation = nil
|
||||
editingExistingAnimation = nil // Reset to nil for new imports
|
||||
showingURLSheet = false
|
||||
|
||||
// Show editor with slight delay to ensure state is set
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
showingEditor = true
|
||||
print("📥 [Import] Opening editor for URL import")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animation Preview Card
|
||||
struct AnimationPreviewCard: View {
|
||||
let animation: CustomIdleAnimation
|
||||
let isSelected: Bool
|
||||
let onSelect: () -> Void
|
||||
let onDelete: (() -> Void)?
|
||||
let onEdit: (() -> Void)?
|
||||
|
||||
@State private var isHovering = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
// Preview area
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
isSelected ? Color.accentColor : Color.clear,
|
||||
lineWidth: 2.5
|
||||
)
|
||||
)
|
||||
|
||||
// Animation preview
|
||||
AnimationPreview(animation: animation)
|
||||
.frame(width: 60, height: 40)
|
||||
|
||||
// Delete button (only for custom animations)
|
||||
if let onDelete = onDelete, isHovering {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
onDelete()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.white, .red)
|
||||
.font(.system(size: 20))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(4)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 100, height: 80)
|
||||
.onHover { hovering in
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
|
||||
// Name and badge
|
||||
VStack(spacing: 4) {
|
||||
Text(animation.name)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(width: 100)
|
||||
|
||||
if isSelected {
|
||||
Text("Selected")
|
||||
.font(.system(size: 10))
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.accentColor)
|
||||
)
|
||||
} else if animation.isBuiltIn {
|
||||
Text("Built-in")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onSelect()
|
||||
}
|
||||
.contextMenu {
|
||||
if let onEdit = onEdit {
|
||||
Button("Edit Animation") {
|
||||
onEdit()
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
if let onDelete = onDelete {
|
||||
Button("Delete", role: .destructive) {
|
||||
onDelete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animation Preview
|
||||
struct AnimationPreview: View {
|
||||
let animation: CustomIdleAnimation
|
||||
|
||||
var body: some View {
|
||||
switch animation.source {
|
||||
case .lottieFile(let url):
|
||||
LottieView(state: LUStateData(
|
||||
type: .loadedFrom(url),
|
||||
speed: animation.speed,
|
||||
loopMode: .loop
|
||||
))
|
||||
.frame(width: 60, height: 40)
|
||||
|
||||
case .lottieURL(let url):
|
||||
LottieView(state: LUStateData(
|
||||
type: .loadedFrom(url),
|
||||
speed: animation.speed,
|
||||
loopMode: .loop
|
||||
))
|
||||
.frame(width: 60, height: 40)
|
||||
|
||||
case .builtInFace:
|
||||
MinimalFaceFeatures(height: 40, width: 60)
|
||||
.scaleEffect(2.0) // Scale up the face for preview
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add Animation Card
|
||||
struct AddAnimationCard: View {
|
||||
let onAddFile: () -> Void
|
||||
let onAddURL: () -> Void
|
||||
|
||||
@State private var showingMenu = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Button {
|
||||
showingMenu.toggle()
|
||||
} label: {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [5, 3]))
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Add")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 100, height: 80)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.popover(isPresented: $showingMenu, arrowEdge: .bottom) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button {
|
||||
showingMenu = false
|
||||
onAddFile()
|
||||
} label: {
|
||||
Label("Import File...", systemImage: "doc.badge.plus")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(8)
|
||||
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
showingMenu = false
|
||||
onAddURL()
|
||||
} label: {
|
||||
Label("Add from URL...", systemImage: "link.badge.plus")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(8)
|
||||
}
|
||||
.frame(width: 180)
|
||||
}
|
||||
|
||||
Text("Add New")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URL Import Sheet
|
||||
struct URLImportSheet: View {
|
||||
@Binding var urlInput: String
|
||||
@Binding var nameInput: String
|
||||
let onImport: () -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Add Animation from URL")
|
||||
.font(.headline)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Lottie JSON URL")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("https://example.com/animation.json", text: $urlInput)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Animation Name")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("My Animation", text: $nameInput)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
onCancel()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Add") {
|
||||
onImport()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(urlInput.isEmpty || nameInput.isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 400)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview {
|
||||
Form {
|
||||
IdleAnimationsSettingsSection()
|
||||
}
|
||||
.frame(width: 600, height: 400)
|
||||
}
|
||||
|
|
@ -1228,6 +1228,9 @@ struct Appearance: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Custom Idle Animations Section
|
||||
IdleAnimationsSettingsSection()
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
ForEach(icons, id: \.self) { icon in
|
||||
|
|
|
|||
294
DynamicIsland/managers/IdleAnimationManager.swift
Normal file
294
DynamicIsland/managers/IdleAnimationManager.swift
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
//
|
||||
// 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]? {
|
||||
print("📦 [IdleAnimationManager] Loading bundled animations...")
|
||||
|
||||
// The JSON files are added as individual resources, not in a folder
|
||||
let bundledFiles = ["Dog waiting", "Moody Dog", "Orange Cat Peeping"]
|
||||
var animations: [CustomIdleAnimation] = []
|
||||
|
||||
for filename in bundledFiles {
|
||||
if let url = Bundle.main.url(forResource: filename, withExtension: "json") {
|
||||
let animation = CustomIdleAnimation(
|
||||
name: filename,
|
||||
source: .lottieFile(url),
|
||||
speed: 1.0,
|
||||
isBuiltIn: true
|
||||
)
|
||||
animations.append(animation)
|
||||
print("✅ [IdleAnimationManager] Loaded bundled animation: \(filename)")
|
||||
} else {
|
||||
print("⚠️ [IdleAnimationManager] Could not find bundled animation: \(filename).json")
|
||||
}
|
||||
}
|
||||
|
||||
guard !animations.isEmpty else {
|
||||
print("⚠️ [IdleAnimationManager] No bundled animations found")
|
||||
return nil
|
||||
}
|
||||
|
||||
print("📦 [IdleAnimationManager] Loaded \(animations.count) bundled animations")
|
||||
return animations
|
||||
}
|
||||
|
||||
// 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) -> 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)
|
||||
}
|
||||
|
||||
// Local file import
|
||||
return importLocalFile(from: url, name: fileName, speed: speed)
|
||||
}
|
||||
|
||||
/// Import a local Lottie JSON file
|
||||
private func importLocalFile(from sourceURL: URL, name: String, speed: CGFloat) -> 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 (transforms will be stored separately)
|
||||
let animation = CustomIdleAnimation(
|
||||
name: name,
|
||||
source: .lottieFile(destinationURL),
|
||||
speed: speed,
|
||||
isBuiltIn: false
|
||||
)
|
||||
|
||||
// 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) -> 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
|
||||
)
|
||||
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,9 +19,14 @@ public final class ImageService: ImageServiceProtocol {
|
|||
|
||||
private init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
|
||||
// Create proper cache directory path
|
||||
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
||||
let artworkCachePath = cacheDir.appendingPathComponent("artwork_cache").path
|
||||
|
||||
let cache = URLCache(memoryCapacity: 50 * 1024 * 1024, // 50MB
|
||||
diskCapacity: 100 * 1024 * 1024, // 100MB
|
||||
diskPath: "artwork_cache")
|
||||
diskPath: artworkCachePath)
|
||||
config.urlCache = cache
|
||||
config.timeoutIntervalForRequest = 15
|
||||
config.timeoutIntervalForResource = 30
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
import Lottie
|
||||
|
||||
private let availableDirectories = FileManager
|
||||
.default
|
||||
|
|
@ -25,6 +26,77 @@ struct CustomVisualizer: Codable, Hashable, Equatable, Defaults.Serializable {
|
|||
var speed: CGFloat = 1.0
|
||||
}
|
||||
|
||||
// MARK: - Custom Idle Animation Models
|
||||
struct CustomIdleAnimation: Codable, Hashable, Equatable, Defaults.Serializable, Identifiable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
var source: AnimationSource
|
||||
var speed: CGFloat = 1.0
|
||||
var isBuiltIn: Bool = false // Track if it's bundled vs user-added
|
||||
|
||||
init(id: UUID = UUID(), name: String, source: AnimationSource, speed: CGFloat = 1.0, isBuiltIn: Bool = false) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.source = source
|
||||
self.speed = speed
|
||||
self.isBuiltIn = isBuiltIn
|
||||
}
|
||||
|
||||
/// 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, Defaults.Serializable {
|
||||
var scale: CGFloat = 1.0
|
||||
var offsetX: CGFloat = 0
|
||||
var offsetY: CGFloat = 0
|
||||
var cropWidth: CGFloat = 30
|
||||
var cropHeight: CGFloat = 20
|
||||
var rotation: CGFloat = 0
|
||||
var opacity: CGFloat = 1.0
|
||||
var paddingBottom: CGFloat = 0 // Allow adjustment to fill notch from bottom
|
||||
var expandWithAnimation: Bool = false // Whether notch should expand horizontally with animation
|
||||
var loopMode: AnimationLoopMode = .loop // Loop mode for animation
|
||||
|
||||
static let `default` = AnimationTransformConfig()
|
||||
}
|
||||
|
||||
enum AnimationLoopMode: String, Codable, CaseIterable {
|
||||
case loop = "Loop"
|
||||
case playOnce = "Play Once"
|
||||
case autoReverse = "Auto Reverse"
|
||||
|
||||
var lottieLoopMode: LottieLoopMode {
|
||||
switch self {
|
||||
case .loop: return .loop
|
||||
case .playOnce: return .playOnce
|
||||
case .autoReverse: return .autoReverse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AnimationSource: Codable, Hashable, Equatable {
|
||||
case lottieFile(URL) // Local file (in app support or bundle)
|
||||
case lottieURL(URL) // Remote URL
|
||||
case builtInFace // Original MinimalFaceFeatures
|
||||
|
||||
var displayType: String {
|
||||
switch self {
|
||||
case .lottieFile: return "Local"
|
||||
case .lottieURL: return "Remote"
|
||||
case .builtInFace: return "Built-in"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CalendarSelectionState: Codable, Defaults.Serializable {
|
||||
case all
|
||||
case selected(Set<String>)
|
||||
|
|
@ -234,6 +306,9 @@ extension Defaults.Keys {
|
|||
static let cornerRadiusScaling = Key<Bool>("cornerRadiusScaling", default: true)
|
||||
static let useModernCloseAnimation = Key<Bool>("useModernCloseAnimation", default: true)
|
||||
static let showNotHumanFace = Key<Bool>("showNotHumanFace", default: false)
|
||||
static let customIdleAnimations = Key<[CustomIdleAnimation]>("customIdleAnimations", default: [])
|
||||
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 showCalendar = Key<Bool>("showCalendar", default: false)
|
||||
static let hideCompletedReminders = Key<Bool>("hideCompletedReminders", default: true)
|
||||
|
|
|
|||
1
LottieAnimations/Dog waiting.json
Normal file
1
LottieAnimations/Dog waiting.json
Normal file
File diff suppressed because one or more lines are too long
1
LottieAnimations/Moody Dog.json
Normal file
1
LottieAnimations/Moody Dog.json
Normal file
File diff suppressed because one or more lines are too long
1
LottieAnimations/Orange Cat Peeping.json
Normal file
1
LottieAnimations/Orange Cat Peeping.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1,362 +0,0 @@
|
|||
# DynamicIsland - boring.notch Migration Summary
|
||||
|
||||
**Migration Date**: October 5-6, 2025
|
||||
**Status**: ✅ **COMPLETE** - All applicable changes merged
|
||||
**Build Status**: ✅ **BUILD SUCCEEDED**
|
||||
|
||||
## Overview
|
||||
|
||||
Successfully migrated all improvements from boring.notch to DynamicIsland while preserving 100% of DynamicIsland's custom features (Timer, Stats, Recording, Clipboard, Screen Assistant, Color Picker, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 📊 Migration Statistics
|
||||
|
||||
- **Total Files Processed**: 33 files
|
||||
- **Files Added**: 6 (YouTube Music Controller, ImageService, etc.)
|
||||
- **Files Updated**: 27 (improvements and refactors)
|
||||
- **Files Preserved**: 5 (DynamicIsland-specific features)
|
||||
- **Languages Added**: 16 (2,646 translations)
|
||||
- **Build Status**: ✅ All changes compile successfully
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 1: New Files (100% Complete)
|
||||
|
||||
### Added Files (6 files):
|
||||
1. ✅ **YouTube Music Controller** (4-file split architecture):
|
||||
- `YouTubeMusicModels.swift` - Data models
|
||||
- `YouTubeMusicNetworking.swift` - API layer
|
||||
- `YouTubeMusicController.swift` - Main controller
|
||||
- `YouTubeMusicAuthentication.swift` - Auth handling
|
||||
|
||||
2. ✅ **managers/ImageService.swift** - Centralized image caching with:
|
||||
- Memory cache management
|
||||
- Async image fetching
|
||||
- Cache invalidation
|
||||
- NSCache integration
|
||||
|
||||
3. ✅ **models/Constants.swift** - Shared constants updates
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 2: Calendar & Media Improvements (83.3% Complete - 10/12 files)
|
||||
|
||||
### Calendar Models (3 files):
|
||||
1. ✅ **models/CalendarModel.swift**
|
||||
- Added `isReminder: Bool` property
|
||||
- Support for reminder calendar detection
|
||||
|
||||
2. ✅ **models/EventModel.swift**
|
||||
- Added `Identifiable` conformance
|
||||
- Better SwiftUI integration
|
||||
|
||||
3. ✅ **Providers/CalendarServiceProviding.swift**
|
||||
- Fixed `CalendarModel.init` (removed account parameter)
|
||||
- Added `isReminder` with `allowedEntityTypes` check
|
||||
|
||||
### Media Controllers (6 files):
|
||||
4. ✅ **MediaControllers/MediaControllerProtocol.swift**
|
||||
- Updated protocol interface
|
||||
- Maintained `isWorking` property for DynamicIsland health checking
|
||||
|
||||
5. ✅ **models/MediaChecker.swift**
|
||||
- Added YouTube Music to bundle identifier mappings
|
||||
|
||||
6. ✅ **MediaControllers/AppleMusicController.swift**
|
||||
- Added `playbackRate: 1` initialization
|
||||
- Improved `toggleShuffle`/`toggleRepeat` with state refresh
|
||||
- Fixed repeat mode values (0→1, 1→2, 2→3)
|
||||
- Added `lastUpdated` tracking
|
||||
- Maintained `isWorking` property
|
||||
|
||||
7. ✅ **MediaControllers/SpotifyController.swift**
|
||||
- Made `commandUpdateDelay` private
|
||||
- Added artwork caching (`lastArtworkURL`, `artworkFetchTask`)
|
||||
- Replaced URLSession with `ImageService.shared.fetchImageData()`
|
||||
- Refactored to one-liners
|
||||
- Added `executeAndRefresh()` helper
|
||||
- Maintained `isWorking` property
|
||||
|
||||
8. ✅ **MediaControllers/NowPlayingController.swift** (342 diff lines - Major refactor):
|
||||
- Complete async/await rewrite
|
||||
- Added `JSONLinesPipeHandler` actor (thread-safe stream processing)
|
||||
- Added `NowPlayingUpdate`/`NowPlayingPayload` Codable structs
|
||||
- Async `setupNowPlayingObserver()` method
|
||||
- Improved elapsed time calculation with playback rate
|
||||
- Fixed shuffle modes (1↔3 mapping correction)
|
||||
- Proper framework path detection
|
||||
|
||||
9. ✅ **managers/MusicManager.swift** (553 diff lines - Major refactor):
|
||||
- Removed `lastUpdated` and `ignoreLastUpdated` properties (unused)
|
||||
- Added artwork tracking (`lastArtworkTitle`, `lastArtworkArtist`, `lastArtworkAlbum`, `lastArtworkBundleIdentifier`)
|
||||
- Added `destroy()` public method for proper cleanup
|
||||
- Simplified `createController()` logic (removed `isWorking` checks, removed `ignoreLastUpdated` assignments)
|
||||
- Added `.receive(on: DispatchQueue.main)` to `playbackStatePublisher`
|
||||
- Removed transition animation logic from `setActiveController()`
|
||||
- Simplified `updateIdleState()` (removed `Task.isCancelled` check, removed `lastUpdated` timestamp logic)
|
||||
- Updated timestamp handling to direct assignment (`state.lastUpdated`)
|
||||
- Improved memory management with `[weak self]` captures
|
||||
- **UI Fixes**: Updated `NotchHomeView` and `MinimalisticMusicPlayerView` to remove references to removed properties
|
||||
|
||||
### Live Activities (1 file):
|
||||
10. ✅ **components/Live activities/LiveActivityModifier.swift**
|
||||
- Updated enum rawValue mapping for activity types
|
||||
|
||||
### Deferred (2 files):
|
||||
- ⚠️ **managers/CalendarManager.swift** - NOW COMPLETED (see Phase 6)
|
||||
- ⚠️ **components/Live activities/SystemEventIndicatorModifier.swift** - PRESERVED (DynamicIsland custom feature)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 3: Core UI Files (50% Complete - 1/2 files)
|
||||
|
||||
### Completed:
|
||||
1. ✅ **ContentView.swift** (873 lines):
|
||||
- Added `@MainActor` attribute for better concurrency safety
|
||||
- Replaced `DispatchWorkItem` with `Task<Void, Never>` in `handleHover()` for cleaner async code
|
||||
- Simplified hover-on logic with Task-based delay
|
||||
- Simplified hover-off logic with Task-based debounce
|
||||
- **Preserved All DynamicIsland Features**:
|
||||
- Timer auto-switch in `doOpen()`
|
||||
- Stats view dynamic sizing logic
|
||||
- All custom managers (Timer, Stats, Recording)
|
||||
- `handleSimpleHover()` with complex stats closing delays (kept DispatchWorkItem for this case)
|
||||
- Battery/Clipboard/ColorPicker/Stats popover checks
|
||||
|
||||
### Deferred:
|
||||
- ⚠️ **components/Settings/SettingsView.swift** (2,152 diff lines, 97KB vs 53KB)
|
||||
- Requires very careful selective merging
|
||||
- DynamicIsland has extensive custom settings
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 4: Localizations (100% Complete)
|
||||
|
||||
### Localization Merge:
|
||||
- **Tool Created**: `merge_localizations.py` - Intelligent localization merger with boring→dynamic renaming
|
||||
- **Result**:
|
||||
- Added **2,646 individual language translations**
|
||||
- Updated **167 strings** with multi-language support
|
||||
- Expanded from **29KB to 414KB**
|
||||
- **16 languages supported**: Arabic, Czech, German, English, English (UK), Spanish, French, Hungarian, Italian, Korean, Polish, Portuguese (Brazil), Russian, Turkish, Ukrainian, Simplified Chinese
|
||||
- **Preservation**: All **429 DynamicIsland custom strings** maintained
|
||||
- **Backup**: Original saved as `Localizable.xcstrings.backup`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 5: Other Modified Files (100% Complete - 7 files)
|
||||
|
||||
### Applied Improvements:
|
||||
1. ✅ **models/PlaybackState.swift**
|
||||
- Added `Equatable` conformance for better state comparison
|
||||
|
||||
2. ✅ **menu/StatusBarMenu.swift**
|
||||
- Fixed NSMenu subclassing with proper init methods
|
||||
- Added required `init(coder:)`, designated `init(title:)`, convenience `init()`
|
||||
|
||||
3. ✅ **observers/FullscreenMediaDetection.swift** (48 lines):
|
||||
- Modernized to async/await
|
||||
- Replaced `@objc` selectors with Task-based notifications
|
||||
- Added `notificationTask: Task<Void, Never>?`
|
||||
- Moved `@MainActor` to Published property only
|
||||
- Cleaner cancellation handling with `[weak self, weak view]`
|
||||
|
||||
4. ✅ **components/Settings/SettingsWindowController.swift** (58 lines):
|
||||
- Improved window focusing logic
|
||||
- Simplified `showWindow()` method
|
||||
- Removed redundant `forceWindowToFront()`
|
||||
- Preserved DynamicIsland screen capture hiding feature
|
||||
|
||||
5. ✅ **extensions/PanGesture.swift** (160 lines - **Major rewrite**):
|
||||
- Combined `DragGesture` with `ScrollMonitor` for better gesture handling
|
||||
- Added threshold support (configurable, default: 4pt)
|
||||
- Improved `PanDirection` enum with `signed()` helper methods
|
||||
- `@MainActor` Coordinator with proper memory management
|
||||
- Noise threshold filtering (0.2pt)
|
||||
- Proper monitor cleanup in `dismantleNSView`
|
||||
- `[weak self, weak view]` captures to prevent memory leaks
|
||||
|
||||
6. ✅ **Script: merge_localizations.py**
|
||||
- Created reusable Python script for future localization merges
|
||||
|
||||
### Skipped Files (DynamicIsland Custom Features):
|
||||
- **models/Constants.swift** (237 lines) - Removes custom enums (ClipboardDisplayMode, ScreenAssistantDisplayMode, ColorPickerDisplayMode)
|
||||
- **components/Notch/NotchHomeView.swift** (258 lines) - UI changes (deferred for testing)
|
||||
- **components/Onboarding/** - Branding and privacy policy changes
|
||||
- **components/Live activities/InlineHUD.swift** - Removes timer HUD
|
||||
- **components/Tabs/TabSelectionView.swift** - Removes dynamic tabs
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 6: Deferred Files (66% Complete - 2/3 files)
|
||||
|
||||
### Completed:
|
||||
1. ✅ **managers/CalendarManager.swift** (196 lines - **Major refactor**):
|
||||
- Added reminder support (`eventCalendars`, `reminderLists` separation)
|
||||
- Event store change observer (auto-reload on calendar changes)
|
||||
- Separate authorization status tracking (`calendarAuthorizationStatus`, `reminderAuthorizationStatus`)
|
||||
- Refactored `checkCalendarAuthorization()` with better error handling
|
||||
- Added `reloadCalendarAndReminderLists()` method
|
||||
- Added `updateEvents()` helper for cleaner code
|
||||
- Proper deinit with observer cleanup
|
||||
- **Fix Applied**: Updated SettingsView to use `calendarAuthorizationStatus`
|
||||
|
||||
### Preserved (DynamicIsland Features):
|
||||
2. ⚠️ **components/Live activities/SystemEventIndicatorModifier.swift** (166 lines):
|
||||
- **Reason**: DynamicIsland has segmented progress bar (`.segmented` style with `SegmentedProgressContent`)
|
||||
- **Reason**: DynamicIsland has `progressBarStyle` enum (`.gradient`, `.hierarchical`, `.segmented`)
|
||||
- **Reason**: boring.notch only has `enableGradient` boolean
|
||||
- **Decision**: Keep DynamicIsland version (MORE features)
|
||||
|
||||
3. ⚠️ **models/Constants.swift** (237 lines):
|
||||
- **Reason**: Removes DynamicIsland-specific enums needed for custom features
|
||||
- **Decision**: Keep DynamicIsland version
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Key Improvements Applied
|
||||
|
||||
### 1. Async/Await Modernization
|
||||
- `FullscreenMediaDetector` - Task-based notifications
|
||||
- `NowPlayingController` - Complete async rewrite
|
||||
- `ContentView` - Task-based hover handling
|
||||
- `PanGesture` - Async gesture processing
|
||||
|
||||
### 2. Memory Management
|
||||
- `[weak self]` captures throughout
|
||||
- Proper observer cleanup in deinit
|
||||
- Monitor removal in `dismantleNSView`
|
||||
- Task cancellation handling
|
||||
|
||||
### 3. Architecture Improvements
|
||||
- YouTube Music Controller split architecture (4 files)
|
||||
- ImageService centralization
|
||||
- JSONLinesPipeHandler actor for thread safety
|
||||
- Better separation of concerns
|
||||
|
||||
### 4. Code Quality
|
||||
- Removed unused properties (`lastUpdated`, `ignoreLastUpdated`)
|
||||
- Simplified logic flows
|
||||
- Better error handling
|
||||
- Cleaner method signatures
|
||||
|
||||
### 5. Multi-Language Support
|
||||
- 16 languages added
|
||||
- 2,646 translations
|
||||
- Professional internationalization
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ DynamicIsland Custom Features Preserved
|
||||
|
||||
All custom features remain fully functional:
|
||||
|
||||
1. ✅ **Timer System** - Complete timer management with UI
|
||||
2. ✅ **Stats System** - System monitoring and visualization
|
||||
3. ✅ **Color Picker** - Advanced color picking tool
|
||||
4. ✅ **Screen Recording** - Recording management
|
||||
5. ✅ **Screen Assistant** - AI assistant integration
|
||||
6. ✅ **Clipboard Enhancement** - Advanced clipboard features
|
||||
7. ✅ **System Monitors** - Volume, Display, OSD managers
|
||||
8. ✅ **Segmented Progress Bar** - Custom `.segmented` style
|
||||
9. ✅ **Dynamic Tabs** - Tab system based on enabled features
|
||||
10. ✅ **Privacy Features** - Screen capture hiding options
|
||||
|
||||
---
|
||||
|
||||
## 📈 Testing Recommendations
|
||||
|
||||
### 1. Calendar & Reminders
|
||||
- [ ] Test calendar event display
|
||||
- [ ] Verify calendar selection works
|
||||
- [ ] Check reminder support
|
||||
- [ ] Validate authorization flow
|
||||
|
||||
### 2. Music Playback
|
||||
- [ ] Test Apple Music integration
|
||||
- [ ] Test Spotify integration
|
||||
- [ ] Test YouTube Music integration
|
||||
- [ ] Verify NowPlaying detection
|
||||
- [ ] Check artwork loading
|
||||
- [ ] Test shuffle/repeat toggles
|
||||
|
||||
### 3. UI & Gestures
|
||||
- [ ] Test hover interactions
|
||||
- [ ] Verify gesture handling
|
||||
- [ ] Check stats view resizing
|
||||
- [ ] Test timer tab switching
|
||||
|
||||
### 4. Multi-Language
|
||||
- [ ] Test language switching
|
||||
- [ ] Verify translations display correctly
|
||||
- [ ] Check RTL languages (Arabic)
|
||||
|
||||
### 5. Custom Features
|
||||
- [ ] Test Timer functionality
|
||||
- [ ] Test Stats display
|
||||
- [ ] Test Color Picker
|
||||
- [ ] Test Screen Recording
|
||||
- [ ] Test Clipboard features
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Build Configuration
|
||||
|
||||
- **Xcode Version**: Compatible with latest
|
||||
- **Swift Version**: 5.0+
|
||||
- **macOS Target**: 13.0+
|
||||
- **Architecture**: arm64, x86_64
|
||||
|
||||
---
|
||||
|
||||
## 📝 Known Issues & Notes
|
||||
|
||||
1. **NowPlaying Progress**: User reported "NowPlaying slider not working" - This may require additional testing
|
||||
2. **SettingsView**: Large file (97KB) not merged due to extensive custom features - selective merge possible in future
|
||||
3. **NotchHomeView**: UI improvements available but deferred for visual testing
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Migration Success Metrics
|
||||
|
||||
- ✅ **0 compilation errors**
|
||||
- ✅ **0 runtime crashes** (based on successful build)
|
||||
- ✅ **100% feature preservation**
|
||||
- ✅ **33 files processed**
|
||||
- ✅ **16 languages added**
|
||||
- ✅ **Multiple architecture improvements**
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Thorough Testing**: Run the application and test all features
|
||||
2. **User Feedback**: Gather feedback on new improvements
|
||||
3. **Documentation**: Update user-facing documentation for new features
|
||||
4. **Release Notes**: Prepare changelog for users
|
||||
5. **Future Merges**: Consider selective SettingsView improvements
|
||||
|
||||
---
|
||||
|
||||
## 📚 Files for Reference
|
||||
|
||||
- **Migration Log**: `rough_note.md` - Detailed phase-by-phase notes
|
||||
- **Localization Backup**: `DynamicIsland/Localizable.xcstrings.backup`
|
||||
- **Merge Script**: `merge_localizations.py` - Reusable for future updates
|
||||
- **Comparison Script**: `smart_compare.py`, `analyze_diff.py` - Project comparison tools
|
||||
|
||||
---
|
||||
|
||||
## 👨💻 Migration Team
|
||||
|
||||
- **Developer**: AI Assistant
|
||||
- **Project Owner**: Hariharan Mudaliar
|
||||
- **Source Project**: boring.notch by Richard Kunkli
|
||||
- **Target Project**: DynamicIsland (fork)
|
||||
|
||||
---
|
||||
|
||||
**Migration Completed**: October 6, 2025
|
||||
**Status**: ✅ **ALL CHANGES SUCCESSFULLY APPLIED**
|
||||
**Build**: ✅ **BUILD SUCCEEDED**
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
# Onboarding Enhancement - User Profile Selection
|
||||
|
||||
## Overview
|
||||
Added comprehensive user profile selection to the onboarding flow, allowing users to customize their DynamicIsland experience based on their work type.
|
||||
|
||||
## What Was Changed
|
||||
|
||||
### 1. New Files Created
|
||||
|
||||
#### `ProfileSelectionView.swift`
|
||||
- **Location**: `DynamicIsland/components/Onboarding/ProfileSelectionView.swift`
|
||||
- **Features**:
|
||||
- Multi-select card interface with 4 user profiles
|
||||
- Beautiful gradient cards with hover effects
|
||||
- Profile-specific settings configuration
|
||||
- At least one profile must be selected to continue
|
||||
|
||||
#### `reset_app_defaults.sh`
|
||||
- **Location**: Root directory
|
||||
- **Purpose**: Reset app to factory defaults for testing
|
||||
- **Usage**: `./reset_app_defaults.sh`
|
||||
|
||||
### 2. Modified Files
|
||||
|
||||
#### `OnboardingView.swift`
|
||||
- Added `profileSelection` step to `OnboardingStep` enum
|
||||
- Integrated `ProfileSelectionView` before finish screen
|
||||
- Added profile settings application logic
|
||||
|
||||
#### `WelcomeView.swift`
|
||||
- Added "Privacy Policy" link button
|
||||
- Links to: `https://ebullioscopic.github.io/DynamicIsland/privacy-policy`
|
||||
|
||||
#### `OnboardingFinishView.swift`
|
||||
- Added "Privacy Policy" link button
|
||||
|
||||
## User Profiles
|
||||
|
||||
### 🔧 Developer
|
||||
**For**: Developers and programmers
|
||||
**Enables**:
|
||||
- ✅ Color Picker
|
||||
- ✅ Stats Monitoring
|
||||
- ✅ Timer
|
||||
- ✅ Screen Assistant
|
||||
- ❌ Mirror (disabled)
|
||||
- ❌ Minimalistic UI (disabled)
|
||||
|
||||
**Note**: Clipboard is always enabled for all profiles
|
||||
|
||||
### 🎨 Designer
|
||||
**For**: Graphic designers and creatives
|
||||
**Enables**:
|
||||
- ✅ Color Picker
|
||||
- ✅ Mirror
|
||||
- ✅ Lighting Effects
|
||||
- ✅ Inline HUD
|
||||
- ❌ Stats (disabled)
|
||||
- ❌ Timer (disabled)
|
||||
- ❌ Screen Assistant (disabled)
|
||||
- ❌ Minimalistic UI (disabled)
|
||||
|
||||
**Note**: Clipboard is always enabled for all profiles
|
||||
|
||||
### ✨ Light Use
|
||||
**For**: Minimal everyday use
|
||||
**Enables**:
|
||||
- ✅ Minimalistic UI (simplified interface)
|
||||
- ✅ Timer
|
||||
- ❌ All other advanced features disabled
|
||||
|
||||
**Note**: Clipboard is always enabled for all profiles
|
||||
|
||||
### 📚 Student
|
||||
**For**: Students and learners
|
||||
**Enables**:
|
||||
- ✅ Timer
|
||||
- ✅ Calendar
|
||||
- ✅ Battery Monitoring
|
||||
- ❌ Color Picker (disabled)
|
||||
- ❌ Mirror (disabled)
|
||||
- ❌ Stats (disabled)
|
||||
- ❌ Screen Assistant (disabled)
|
||||
- ❌ Minimalistic UI (disabled)
|
||||
|
||||
**Note**: Clipboard is always enabled for all profiles
|
||||
|
||||
## Multi-Select Logic
|
||||
|
||||
- Users can select **one or more** profiles
|
||||
- Settings use **OR logic**: If ANY selected profile enables a feature, it's enabled
|
||||
- **Exception**: Minimalistic UI is only enabled if "Light Use" is selected AND no other profiles are selected
|
||||
- Common settings (menubar icon, haptics) enabled for all profiles
|
||||
|
||||
## Privacy Policy Integration
|
||||
|
||||
- Links added to Welcome and Finish screens
|
||||
- Opens in default browser
|
||||
- URL: `https://ebullioscopic.github.io/DynamicIsland/privacy-policy`
|
||||
- **Note**: You need to create this page on GitHub Pages
|
||||
|
||||
## Reset Script Usage
|
||||
|
||||
To test the onboarding flow fresh:
|
||||
|
||||
```bash
|
||||
# Run the reset script
|
||||
./reset_app_defaults.sh
|
||||
|
||||
# The script will:
|
||||
# 1. Kill the app if running
|
||||
# 2. Clear UserDefaults
|
||||
# 3. Remove Application Support files
|
||||
# 4. Remove caches
|
||||
# 5. Remove preference files
|
||||
# 6. Remove saved state
|
||||
|
||||
# Then launch the app
|
||||
open -a DynamicIsland
|
||||
```
|
||||
|
||||
## Updated Onboarding Flow
|
||||
|
||||
**New Flow**:
|
||||
1. Welcome Screen (with Privacy Policy link)
|
||||
2. Camera Permission
|
||||
3. Calendar Permission
|
||||
4. Music Controller Selection
|
||||
5. **✨ Profile Selection** (NEW!)
|
||||
6. Finish Screen (with Privacy Policy link)
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Profile cards display correctly
|
||||
- [ ] Multi-select works (can select/deselect profiles)
|
||||
- [ ] Cannot continue without selecting at least one profile
|
||||
- [ ] Hover effects work on profile cards
|
||||
- [ ] Settings are applied correctly for each profile combination
|
||||
- [ ] Privacy Policy links open correct URL
|
||||
- [ ] Reset script clears all app data
|
||||
- [ ] Onboarding shows on fresh launch after reset
|
||||
- [ ] Selected profile settings persist after closing onboarding
|
||||
|
||||
## Profile Settings Matrix
|
||||
|
||||
| Setting | Developer | Designer | Light Use | Student | Always Enabled |
|
||||
|---------|-----------|----------|-----------|---------|----------------|
|
||||
| **Clipboard** | ✅ | ✅ | ✅ | ✅ | **YES** |
|
||||
| Minimalistic UI | OFF | OFF | **ON** | OFF | - |
|
||||
| Color Picker | **ON** | **ON** | OFF | OFF | - |
|
||||
| Mirror | OFF | **ON** | OFF | OFF | - |
|
||||
| Stats | **ON** | OFF | OFF | OFF | - |
|
||||
| Timer | **ON** | OFF | **ON** | **ON** | - |
|
||||
| Screen Assistant | **ON** | OFF | OFF | OFF | - |
|
||||
| Lighting Effects | - | **ON** | - | - | - |
|
||||
| Inline HUD | - | **ON** | OFF | - | - |
|
||||
| Calendar | - | - | - | **ON** | - |
|
||||
|
||||
**Note**: Clipboard Manager is always enabled regardless of profile selection.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Create GitHub Pages Privacy Policy**:
|
||||
- Create `docs/privacy-policy.md` in the repository
|
||||
- Enable GitHub Pages for the repo
|
||||
- Add actual privacy policy content
|
||||
|
||||
2. **Test All Profile Combinations**:
|
||||
- Single profiles (4 tests)
|
||||
- Common combinations (Developer+Designer, etc.)
|
||||
- All profiles selected
|
||||
|
||||
3. **Verify Settings Integration**:
|
||||
- Check that disabled features don't appear in UI
|
||||
- Verify minimalistic mode works correctly
|
||||
- Test feature toggles in settings
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Profile selection is **required** (cannot skip)
|
||||
- Settings are applied immediately when continuing
|
||||
- Profile data is stored in UserDefaults
|
||||
- Reset script is safe - only clears app-specific data
|
||||
- All UI uses SwiftUI with proper transitions and animations
|
||||
Loading…
Add table
Add a link
Reference in a new issue