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
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -81,4 +81,5 @@ PrivateWorks
|
||||||
*.py
|
*.py
|
||||||
*.sh
|
*.sh
|
||||||
comparison_report.json
|
comparison_report.json
|
||||||
*.txt
|
*.txt
|
||||||
|
ANIMATION_DEBUG_GUIDE.md
|
||||||
|
|
@ -21,6 +21,9 @@
|
||||||
7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 146BC4362D366E9B0047C66A /* MacroVisionKit */; };
|
7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 146BC4362D366E9B0047C66A /* MacroVisionKit */; };
|
||||||
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */; };
|
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */; };
|
||||||
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */; };
|
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 */; };
|
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; };
|
||||||
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; };
|
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; };
|
||||||
BA90505C953246D0AB206451 /* MediaRemoteAdapter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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; };
|
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; };
|
F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; };
|
||||||
|
|
@ -109,9 +115,20 @@
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
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 = {
|
F54406DE2CD4987A00C7AFD9 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
7E8840D52E9A341F00C5CB8C /* LottieAnimations */,
|
||||||
7E81304F2E3FB42700E08F3F /* Frameworks */,
|
7E81304F2E3FB42700E08F3F /* Frameworks */,
|
||||||
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
|
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
|
||||||
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
|
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
|
||||||
|
|
@ -215,6 +232,9 @@
|
||||||
files = (
|
files = (
|
||||||
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */,
|
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */,
|
||||||
7E8130412E3FB40500E08F3F /* NowPlayingTestClient 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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -516,7 +516,7 @@ struct ContentView: View {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(.black)
|
.fill(.black)
|
||||||
.frame(width: vm.closedNotchSize.width - 20)
|
.frame(width: vm.closedNotchSize.width - 20)
|
||||||
MinimalFaceFeatures()
|
IdleAnimationView()
|
||||||
}
|
}
|
||||||
}.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
}.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
let webcamManager = WebcamManager.shared
|
let webcamManager = WebcamManager.shared
|
||||||
let dndManager = DoNotDisturbManager.shared // NEW: DND detection
|
let dndManager = DoNotDisturbManager.shared // NEW: DND detection
|
||||||
let bluetoothAudioManager = BluetoothAudioManager.shared // NEW: Bluetooth audio detection
|
let bluetoothAudioManager = BluetoothAudioManager.shared // NEW: Bluetooth audio detection
|
||||||
|
let idleAnimationManager = IdleAnimationManager.shared // NEW: Custom idle animations
|
||||||
var closeNotchWorkItem: DispatchWorkItem?
|
var closeNotchWorkItem: DispatchWorkItem?
|
||||||
private var previousScreens: [NSScreen]?
|
private var previousScreens: [NSScreen]?
|
||||||
private var onboardingWindowController: NSWindowController?
|
private var onboardingWindowController: NSWindowController?
|
||||||
|
|
@ -402,6 +403,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
// Migrate legacy progress bar settings
|
// Migrate legacy progress bar settings
|
||||||
Defaults.Keys.migrateProgressBarStyle()
|
Defaults.Keys.migrateProgressBarStyle()
|
||||||
|
|
||||||
|
// Initialize idle animations (load bundled + built-in face)
|
||||||
|
idleAnimationManager.initializeDefaultAnimations()
|
||||||
|
|
||||||
// Setup SystemHUD Manager
|
// Setup SystemHUD Manager
|
||||||
SystemHUDManager.shared.setup(coordinator: coordinator)
|
SystemHUDManager.shared.setup(coordinator: coordinator)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -810,6 +810,10 @@
|
||||||
},
|
},
|
||||||
"%lld" : {
|
"%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" : {
|
"%lld colors" : {
|
||||||
|
|
||||||
|
|
@ -917,6 +921,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"%lldx" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"•" : {
|
"•" : {
|
||||||
"comment" : "A period used to separate two related items.",
|
"comment" : "A period used to separate two related items.",
|
||||||
|
|
@ -1233,6 +1240,9 @@
|
||||||
},
|
},
|
||||||
"+%lld more" : {
|
"+%lld more" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"1x" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"3 items" : {
|
"3 items" : {
|
||||||
|
|
||||||
|
|
@ -1254,9 +1264,18 @@
|
||||||
},
|
},
|
||||||
"15 colors" : {
|
"15 colors" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"20" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"20 colors" : {
|
"20 colors" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"30" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"100%" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"About" : {
|
"About" : {
|
||||||
"localizations" : {
|
"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 files" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Add from URL..." : {
|
||||||
|
"comment" : "A button label that prompts the user to add a Lottie animation from a URL.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Add manually" : {
|
"Add manually" : {
|
||||||
"localizations" : {
|
"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" : {
|
"Add new visualizer" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"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" : {
|
"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?" : {
|
"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" : {
|
"Bluetooth Audio Devices" : {
|
||||||
"comment" : "A section header that lists Bluetooth audio devices.",
|
"comment" : "A section header that lists Bluetooth audio devices.",
|
||||||
|
|
@ -3524,9 +3569,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Bottom Pad:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Brightness HUD" : {
|
"Brightness HUD" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Built-in" : {
|
||||||
|
"comment" : "A label indicating that an animation is a built-in one.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Built-in System Monitoring" : {
|
"Built-in System Monitoring" : {
|
||||||
|
|
||||||
|
|
@ -3836,6 +3888,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Center" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Change" : {
|
"Change" : {
|
||||||
|
|
||||||
|
|
@ -4245,6 +4300,10 @@
|
||||||
},
|
},
|
||||||
"Choose AI model" : {
|
"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" : {
|
"Choose File" : {
|
||||||
|
|
||||||
|
|
@ -5188,6 +5247,9 @@
|
||||||
},
|
},
|
||||||
"Custom: %@" : {
|
"Custom: %@" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Customize Animation" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Customize in Settings" : {
|
"Customize in Settings" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -5394,6 +5456,10 @@
|
||||||
},
|
},
|
||||||
"Delete" : {
|
"Delete" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Delete Animation" : {
|
||||||
|
"comment" : "The title of an alert that appears when trying to delete a custom animation.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Delete Color" : {
|
"Delete Color" : {
|
||||||
|
|
||||||
|
|
@ -5714,6 +5780,9 @@
|
||||||
},
|
},
|
||||||
"Display Mode" : {
|
"Display Mode" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Display Options" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Displays a HUD notification when Bluetooth audio devices (headphones, AirPods, speakers) connect, showing device name and battery level." : {
|
"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.",
|
"comment" : "A footer for the \"Bluetooth Audio Devices\" section in the HUD settings.",
|
||||||
|
|
@ -6150,6 +6219,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Edit Animation" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Edit layout" : {
|
"Edit layout" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -6253,6 +6325,10 @@
|
||||||
},
|
},
|
||||||
"Email address" : {
|
"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" : {
|
"Enable Built-in System HUD" : {
|
||||||
|
|
||||||
|
|
@ -7108,6 +7184,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Expand Notch with Animation" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Extensions" : {
|
"Extensions" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -7208,6 +7287,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Fill Notch" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Finish" : {
|
"Finish" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -7308,6 +7390,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Fit to Notch" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Gemini API Key" : {
|
"Gemini API Key" : {
|
||||||
|
|
||||||
|
|
@ -8020,6 +8105,9 @@
|
||||||
},
|
},
|
||||||
"Graph Visibility" : {
|
"Graph Visibility" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Height:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Hide" : {
|
"Hide" : {
|
||||||
|
|
||||||
|
|
@ -8329,6 +8417,13 @@
|
||||||
},
|
},
|
||||||
"History Size" : {
|
"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" : {
|
"https://github.com/th-ch/youtube-music" : {
|
||||||
"localizations" : {
|
"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" : {
|
"In progress" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -9061,6 +9171,12 @@
|
||||||
},
|
},
|
||||||
"Live Performance Data" : {
|
"Live Performance Data" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Loop Mode" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Loop Mode:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Lottie JSON URL" : {
|
"Lottie JSON URL" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -11290,6 +11406,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"My Animation" : {
|
||||||
|
"comment" : "A default name for a new animation added via a URL.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Name" : {
|
"Name" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -12760,6 +12880,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Opacity:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Open Google AI Studio" : {
|
"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." : {
|
"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" : {
|
"Privacy & Security" : {
|
||||||
"comment" : "The title of the privacy and security settings screen.",
|
"comment" : "The title of the privacy and security settings screen.",
|
||||||
|
|
@ -13070,6 +13202,9 @@
|
||||||
},
|
},
|
||||||
"Quick Actions" : {
|
"Quick Actions" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Quick Presets" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Quit" : {
|
"Quit" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -13507,6 +13642,12 @@
|
||||||
},
|
},
|
||||||
"Replaces system HUD notifications with Dynamic Island displays." : {
|
"Replaces system HUD notifications with Dynamic Island displays." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Reset" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Reset All" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Reset to Default" : {
|
"Reset to Default" : {
|
||||||
|
|
||||||
|
|
@ -13616,6 +13757,9 @@
|
||||||
},
|
},
|
||||||
"Resume" : {
|
"Resume" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Rotation:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Running" : {
|
"Running" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -13719,9 +13863,21 @@
|
||||||
},
|
},
|
||||||
"Save" : {
|
"Save" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Save Changes" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Save Configuration" : {
|
"Save Configuration" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Scale Down" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Scale Up 2x" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Scale:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Screen Assistant" : {
|
"Screen Assistant" : {
|
||||||
|
|
||||||
|
|
@ -13978,6 +14134,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Selected" : {
|
||||||
|
"comment" : "A label indicating that an animation is currently selected.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Selected animation" : {
|
"Selected animation" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -14919,6 +15079,9 @@
|
||||||
},
|
},
|
||||||
"Shows the model's reasoning process before the final answer" : {
|
"Shows the model's reasoning process before the final answer" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Size & Scale" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Slider color" : {
|
"Slider color" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -15427,6 +15590,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Speed:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Square" : {
|
"Square" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -16222,6 +16388,9 @@
|
||||||
"Top Processes by %@" : {
|
"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.",
|
"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
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Transform" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Transform your notch truly yours" : {
|
"Transform your notch truly yours" : {
|
||||||
|
|
||||||
|
|
@ -17039,6 +17208,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Vertical:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"View on GitHub: th-ch/youtube-music" : {
|
"View on GitHub: th-ch/youtube-music" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -17345,9 +17517,15 @@
|
||||||
},
|
},
|
||||||
"When disabled, all keyboard shortcuts will be inactive. You can still use the UI controls." : {
|
"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." : {
|
"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." : {
|
"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)
|
||||||
|
}
|
||||||
|
|
@ -1227,6 +1227,9 @@ struct Appearance: View {
|
||||||
Text("Additional features")
|
Text("Additional features")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Custom Idle Animations Section
|
||||||
|
IdleAnimationsSettingsSection()
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
|
||||||
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() {
|
private init() {
|
||||||
let config = URLSessionConfiguration.default
|
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
|
let cache = URLCache(memoryCapacity: 50 * 1024 * 1024, // 50MB
|
||||||
diskCapacity: 100 * 1024 * 1024, // 100MB
|
diskCapacity: 100 * 1024 * 1024, // 100MB
|
||||||
diskPath: "artwork_cache")
|
diskPath: artworkCachePath)
|
||||||
config.urlCache = cache
|
config.urlCache = cache
|
||||||
config.timeoutIntervalForRequest = 15
|
config.timeoutIntervalForRequest = 15
|
||||||
config.timeoutIntervalForResource = 30
|
config.timeoutIntervalForResource = 30
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Defaults
|
import Defaults
|
||||||
|
import Lottie
|
||||||
|
|
||||||
private let availableDirectories = FileManager
|
private let availableDirectories = FileManager
|
||||||
.default
|
.default
|
||||||
|
|
@ -25,6 +26,77 @@ struct CustomVisualizer: Codable, Hashable, Equatable, Defaults.Serializable {
|
||||||
var speed: CGFloat = 1.0
|
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 {
|
enum CalendarSelectionState: Codable, Defaults.Serializable {
|
||||||
case all
|
case all
|
||||||
case selected(Set<String>)
|
case selected(Set<String>)
|
||||||
|
|
@ -234,6 +306,9 @@ extension Defaults.Keys {
|
||||||
static let cornerRadiusScaling = Key<Bool>("cornerRadiusScaling", default: true)
|
static let cornerRadiusScaling = Key<Bool>("cornerRadiusScaling", default: true)
|
||||||
static let useModernCloseAnimation = Key<Bool>("useModernCloseAnimation", default: true)
|
static let useModernCloseAnimation = Key<Bool>("useModernCloseAnimation", default: true)
|
||||||
static let showNotHumanFace = Key<Bool>("showNotHumanFace", default: false)
|
static let showNotHumanFace = Key<Bool>("showNotHumanFace", default: false)
|
||||||
|
static let customIdleAnimations = Key<[CustomIdleAnimation]>("customIdleAnimations", default: [])
|
||||||
|
static let selectedIdleAnimation = Key<CustomIdleAnimation?>("selectedIdleAnimation", default: nil)
|
||||||
|
static let animationTransformOverrides = Key<[String: AnimationTransformConfig]>("animationTransformOverrides", default: [:])
|
||||||
static let tileShowLabels = Key<Bool>("tileShowLabels", default: false)
|
static let tileShowLabels = Key<Bool>("tileShowLabels", default: false)
|
||||||
static let showCalendar = Key<Bool>("showCalendar", default: false)
|
static let showCalendar = Key<Bool>("showCalendar", default: false)
|
||||||
static let hideCompletedReminders = Key<Bool>("hideCompletedReminders", default: true)
|
static let hideCompletedReminders = Key<Bool>("hideCompletedReminders", default: true)
|
||||||
|
|
|
||||||
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