mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #135 from Ebullioscopic/revamp/essentials
Revamp/essentials
This commit is contained in:
commit
a0e8078766
16 changed files with 1571 additions and 572 deletions
|
|
@ -171,24 +171,27 @@ struct ContentView: View {
|
||||||
|
|
||||||
mainLayout
|
mainLayout
|
||||||
.conditionalModifier(!useModernCloseAnimation) { view in
|
.conditionalModifier(!useModernCloseAnimation) { view in
|
||||||
let hoverAnimationAnimation = Animation.bouncy.speed(1.2)
|
let hoverAnimation = Animation.bouncy.speed(1.2)
|
||||||
let notchStateAnimation = Animation.spring.speed(1.2)
|
|
||||||
let viewTransitionAnimation = Animation.easeInOut(duration: 0.4)
|
|
||||||
return view
|
|
||||||
.animation(hoverAnimationAnimation, value: isHovering)
|
|
||||||
.animation(notchStateAnimation, value: vm.notchState)
|
|
||||||
.animation(viewTransitionAnimation, value: coordinator.currentView)
|
|
||||||
.animation(.smooth, value: gestureProgress)
|
|
||||||
.transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2)))
|
|
||||||
}
|
|
||||||
.conditionalModifier(useModernCloseAnimation) { view in
|
|
||||||
let hoverAnimationAnimation = Animation.bouncy.speed(1.2)
|
|
||||||
let notchStateAnimation = Animation.spring.speed(1.2)
|
let notchStateAnimation = Animation.spring.speed(1.2)
|
||||||
let viewTransitionAnimation = Animation.easeInOut(duration: 0.4)
|
let viewTransitionAnimation = Animation.easeInOut(duration: 0.4)
|
||||||
return view
|
return view
|
||||||
.animation(hoverAnimationAnimation, value: isHovering)
|
.animation(hoverAnimation, value: isHovering)
|
||||||
.animation(notchStateAnimation, value: vm.notchState)
|
.animation(notchStateAnimation, value: vm.notchState)
|
||||||
.animation(viewTransitionAnimation, value: coordinator.currentView)
|
.animation(viewTransitionAnimation, value: coordinator.currentView)
|
||||||
|
.animation(.smooth, value: gestureProgress)
|
||||||
|
.transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2)))
|
||||||
|
}
|
||||||
|
.conditionalModifier(useModernCloseAnimation) { view in
|
||||||
|
let hoverAnimation = Animation.bouncy.speed(1.2)
|
||||||
|
let openAnimation = Animation.spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0)
|
||||||
|
let closeAnimation = Animation.spring(response: 0.45, dampingFraction: 1.0, blendDuration: 0)
|
||||||
|
let viewTransitionAnimation = Animation.easeInOut(duration: 0.4)
|
||||||
|
let notchAnimation = vm.notchState == .open ? openAnimation : closeAnimation
|
||||||
|
return view
|
||||||
|
.animation(hoverAnimation, value: isHovering)
|
||||||
|
.animation(notchAnimation, value: vm.notchState)
|
||||||
|
.animation(viewTransitionAnimation, value: coordinator.currentView)
|
||||||
|
.animation(.smooth, value: gestureProgress)
|
||||||
}
|
}
|
||||||
.conditionalModifier(interactionsEnabled) { view in
|
.conditionalModifier(interactionsEnabled) { view in
|
||||||
view
|
view
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ class DynamicIslandViewCoordinator: ObservableObject {
|
||||||
sneakPeekDuration = resolvedDuration
|
sneakPeekDuration = resolvedDuration
|
||||||
if type != .music && type != .timer && type != .reminder {
|
if type != .music && type != .timer && type != .reminder {
|
||||||
// close()
|
// close()
|
||||||
if !hudReplacement {
|
if !Defaults[.enableSystemHUD] {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -631,6 +631,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"%@" : {
|
"%@" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|
@ -1119,6 +1120,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"• Frosted Glass: Translucent blur effect" : {
|
||||||
|
"comment" : "A bullet point in the \"Appearance\" section of the Custom OSD settings, describing a glass material option.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"• Improved performance" : {
|
"• Improved performance" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -1219,6 +1224,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"• Liquid Glass: Modern glass effect (macOS 26+)" : {
|
||||||
|
"comment" : "A description of the \"Liquid Glass\" option in the Custom OSD settings.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"• New feature 1" : {
|
"• New feature 1" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -1319,6 +1328,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"• Solid Dark/Light/Auto: Opaque backgrounds" : {
|
||||||
|
"comment" : "A bullet point in the \"Appearance\" section of the Custom OSD settings, describing the appearance of the OSD when using solid colors.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"• Thinking" : {
|
"• Thinking" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -1790,6 +1803,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Accessibility" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Accessibility permission required" : {
|
||||||
|
"comment" : "A label explaining that accessibility permissions are required.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Actions" : {
|
"Actions" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -2324,6 +2344,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Adjust settings above to see changes in real-time. The actual OSD appears at the bottom center of your screen." : {
|
||||||
|
"comment" : "A footer in the \"Preview\" section of the Custom OSD settings, explaining how to use the live preview feature.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"AI Assistant" : {
|
"AI Assistant" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -2797,6 +2821,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ALPHA" : {
|
||||||
|
"comment" : "A label indicating a feature that is still in development.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Alpha Feature Warning" : {
|
||||||
|
"comment" : "The title of an alert that warns users about an alpha feature.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Always hide in fullscreen" : {
|
"Always hide in fullscreen" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -3270,6 +3302,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Atoll" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Atoll"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Attached Files" : {
|
"Attached Files" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -3280,6 +3322,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Auto-scroll to next event" : {
|
||||||
|
"comment" : "A toggle that enables or disables the automatic scrolling to the next event in the calendar view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Automatically check for updates" : {
|
"Automatically check for updates" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -3492,6 +3538,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Backlight" : {
|
||||||
|
"comment" : "A control for adjusting the keyboard backlight intensity.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Battery" : {
|
"Battery" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -4074,6 +4124,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Brightness" : {
|
||||||
|
"comment" : "A control that adjusts the brightness of the device.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Brightness HUD" : {
|
"Brightness HUD" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -4084,6 +4138,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Brightness OSD" : {
|
||||||
|
"comment" : "A label for a toggle that controls whether the brightness OSD is enabled",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Built-in" : {
|
"Built-in" : {
|
||||||
"comment" : "A label indicating that an animation is a built-in one.",
|
"comment" : "A label indicating that an animation is a built-in one.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -4097,6 +4155,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Built-in System Monitoring" : {
|
"Built-in System Monitoring" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|
@ -4107,6 +4166,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Built-in system monitoring detects volume, brightness, and keyboard backlight changes directly without requiring external apps." : {
|
"Built-in system monitoring detects volume, brightness, and keyboard backlight changes directly without requiring external apps." : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|
@ -4997,6 +5057,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Choose which system controls should display custom OSD windows." : {
|
||||||
|
"comment" : "A footer description for the \"Controls\" section of the Custom OSD settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Choose which system controls should display HUD notifications." : {
|
||||||
|
"comment" : "A footer for the \"Controls\" section in the HUD settings, explaining that users can choose which system controls should display HUD notifications.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Choose your preferred AI model and configuration" : {
|
"Choose your preferred AI model and configuration" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -5448,6 +5516,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Color options control the icon and progress bar appearance. Auto adapts to system theme." : {
|
||||||
|
"comment" : "A description under the \"Appearance\" section of the Custom OSD settings, explaining that the color options control the icon and progress bar appearance, and that \"Auto\" automatically adapts to the system theme.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Color Picked!" : {
|
"Color Picked!" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -5754,9 +5826,8 @@
|
||||||
"comment" : "A footer for the \"Timer Widget\" section in the Lock Screen Settings view.",
|
"comment" : "A footer for the \"Timer Widget\" section in the Lock Screen Settings view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Controls whether Atoll mirrors lock/unlock events with its own live activity and audible chimes." : {
|
"Controls whether Dynamic Island mirrors lock/unlock events with its own live activity and audible chimes." : {
|
||||||
"comment" : "A footer for the \"Live Activity & Feedback\" section in the Lock Screen Settings.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
},
|
||||||
"Copy event link" : {
|
"Copy event link" : {
|
||||||
"comment" : "A tooltip for a button that copies the link of a reminder event to the clipboard.",
|
"comment" : "A tooltip for a button that copies the link of a reminder event to the clipboard.",
|
||||||
|
|
@ -6192,6 +6263,21 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Custom OSD" : {
|
||||||
|
"comment" : "The title of the settings screen for customizing the OSD.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Custom OSD feature requires macOS 15 or later." : {
|
||||||
|
"comment" : "A description of the Custom OSD feature, visible only on macOS 15 or later.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Custom OSD is an experimental alpha feature and may contain bugs or unexpected behavior.\n\nThis feature requires macOS 15 or later and is still under active development. It's recommended to keep it disabled unless you want to help test it." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Custom OSD is disabled because HUDs are enabled. Disable HUDs in the HUDs tab to use Custom OSD." : {
|
||||||
|
"comment" : "A message displayed below the toggle to inform the user that they cannot enable Custom OSD if HUDs are enabled.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Custom Timer" : {
|
"Custom Timer" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -6919,6 +7005,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Display custom macOS-style OSD windows at the bottom center of the screen when adjusting volume, brightness, or keyboard backlight." : {
|
||||||
|
"comment" : "A description of the functionality of the Custom OSD feature.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Display Mode" : {
|
"Display Mode" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -7302,16 +7392,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Atoll" : {
|
|
||||||
"localizations" : {
|
|
||||||
"fr" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Atoll"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dynamic.island" : {
|
"dynamic.island" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -7560,7 +7640,12 @@
|
||||||
"comment" : "A description of the media panel settings in the lock screen settings view.",
|
"comment" : "A description of the media panel settings in the lock screen settings view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Enable Anyway" : {
|
||||||
|
"comment" : "A button that enables the Custom OSD feature despite the warning.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Enable Built-in System HUD" : {
|
"Enable Built-in System HUD" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|
@ -7571,6 +7656,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Enable built-in system monitoring to replace macOS HUD notifications with Atoll displays." : {
|
"Enable built-in system monitoring to replace macOS HUD notifications with Atoll displays." : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|
@ -7691,6 +7777,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Enable Custom OSD" : {
|
||||||
|
"comment" : "A toggle that enables or disables the custom OSD feature.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Enable global keyboard shortcuts above to customize your shortcuts." : {
|
"Enable global keyboard shortcuts above to customize your shortcuts." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
@ -7800,6 +7890,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Enable HUD replacement" : {
|
"Enable HUD replacement" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|
@ -7899,6 +7990,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Enable HUDs" : {
|
||||||
|
"comment" : "A toggle that enables or disables the use of HUDs.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Enable music live activity" : {
|
"Enable music live activity" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -8350,6 +8445,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Event List" : {
|
||||||
|
"comment" : "A section header in the calendar settings view, describing the list of calendar events.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Exclude apps" : {
|
"Exclude apps" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
|
|
@ -9434,6 +9533,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Grant Accessibility permission in System Settings to control macOS HUD replacements from here." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Grant Accessibility permission in System Settings to customize the on-screen display replacements here." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Graph Visibility" : {
|
"Graph Visibility" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -9465,6 +9570,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Hide all-day events" : {
|
||||||
|
"comment" : "A toggle to hide all-day events in the calendar.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Hide completed reminders" : {
|
||||||
|
"comment" : "A toggle that lets the user hide completed reminders",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Hide DynamicIsland Options" : {
|
"Hide DynamicIsland Options" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -10115,6 +10228,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"HUDs are disabled because Custom OSD is enabled. Disable Custom OSD in the Custom OSD tab to use HUDs." : {
|
||||||
|
"comment" : "A message displayed below the toggle to disable HUDs, explaining that it's disabled because Custom OSD is enabled.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Icon & Progress Color" : {
|
||||||
|
"comment" : "A picker that lets users choose the color scheme for the icon and progress bar in the OSD.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Idle" : {
|
"Idle" : {
|
||||||
"comment" : "A label displayed alongside the status of a GPU device.",
|
"comment" : "A label displayed alongside the status of a GPU device.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -10537,6 +10658,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Keyboard Backlight OSD" : {
|
||||||
|
"comment" : "A label displayed above the toggle for the keyboard backlight OSD.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Keyboard shortcuts are disabled" : {
|
"Keyboard shortcuts are disabled" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -10723,6 +10848,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Live Preview" : {
|
||||||
|
"comment" : "A section header that indicates the live preview of custom OSD settings.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Live stream indicator" : {
|
"Live stream indicator" : {
|
||||||
"comment" : "A visual indicator that appears when a music stream is live.",
|
"comment" : "A visual indicator that appears when a music stream is live.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|
@ -11074,6 +11203,10 @@
|
||||||
"Mac battery at %d percent" : {
|
"Mac battery at %d percent" : {
|
||||||
"comment" : "Mac battery gauge accessibility label"
|
"comment" : "Mac battery gauge accessibility label"
|
||||||
},
|
},
|
||||||
|
"macOS 15 or later required" : {
|
||||||
|
"comment" : "A message displayed when the Custom OSD feature is unavailable on this version of macOS.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Made with ❤️ by Ebullioscopic" : {
|
"Made with ❤️ by Ebullioscopic" : {
|
||||||
"comment" : "A footer text that credits the developer of the app.",
|
"comment" : "A footer text that credits the developer of the app.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|
@ -11084,6 +11217,14 @@
|
||||||
},
|
},
|
||||||
"Manage the Clock timer from the Clock app." : {
|
"Manage the Clock timer from the Clock app." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Mark as complete" : {
|
||||||
|
"comment" : "An accessibility label for a button that marks an item as complete.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Mark as incomplete" : {
|
||||||
|
"comment" : "An accessibility label for a button that marks an item as incomplete.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Match menubar height" : {
|
"Match menubar height" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -11289,6 +11430,10 @@
|
||||||
"comment" : "A picker that lets the user select the material of the lock screen glass.",
|
"comment" : "A picker that lets the user select the material of the lock screen glass.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Material Options:" : {
|
||||||
|
"comment" : "A label describing the material options for the custom OSD.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Max Capacity: %lld%%" : {
|
"Max Capacity: %lld%%" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -13609,6 +13754,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"No events" : {
|
||||||
|
"comment" : "A description displayed when there are no events in the calendar.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"No events today" : {
|
"No events today" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -14620,6 +14769,10 @@
|
||||||
},
|
},
|
||||||
"Open Model Settings" : {
|
"Open Model Settings" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Open Settings" : {
|
||||||
|
"comment" : "A button that opens system settings when pressed.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Open System Settings" : {
|
"Open System Settings" : {
|
||||||
|
|
||||||
|
|
@ -15495,8 +15648,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Replaces system HUD notifications with Atoll displays." : {
|
"Replaces macOS system HUD with Dynamic Island displays for volume, brightness, and keyboard backlight." : {
|
||||||
|
"comment" : "A footer description of the HUDs feature.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Request Access" : {
|
"Request Access" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -16288,6 +16442,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Sensitivity" : {
|
"Sensitivity" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|
@ -16883,6 +17038,10 @@
|
||||||
},
|
},
|
||||||
"Show floating pause/stop controls" : {
|
"Show floating pause/stop controls" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Show full event titles" : {
|
||||||
|
"comment" : "A toggle that shows or hides the full event titles in the calendar view",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Show media output control in other layouts" : {
|
"Show media output control in other layouts" : {
|
||||||
"comment" : "A toggle that lets the user enable or disable the media output control in layouts other than the home screen.",
|
"comment" : "A toggle that lets the user enable or disable the media output control in layouts other than the home screen.",
|
||||||
|
|
@ -18794,8 +18953,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Toggle the Atoll open or closed from anywhere." : {
|
"Toggle the Dynamic Island open or closed from anywhere." : {
|
||||||
|
"comment" : "A description of the \"Toggle Navigation\" keyboard shortcut.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Total · %@" : {
|
"Total · %@" : {
|
||||||
"comment" : "A label displaying the total storage capacity of a disk device. The argument is the total storage capacity of the disk device.",
|
"comment" : "A label displaying the total storage capacity of a disk device. The argument is the total storage capacity of the disk device.",
|
||||||
|
|
@ -19860,6 +20020,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Volume" : {
|
||||||
|
"comment" : "A button that sets the preview to show a volume OSD.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Volume HUD" : {
|
"Volume HUD" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -19870,6 +20034,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Volume OSD" : {
|
||||||
|
"comment" : "A toggle that enables or disables the volume OSD",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Weather" : {
|
"Weather" : {
|
||||||
"comment" : "A label and its accompanying text displayed in a lock screen panel.",
|
"comment" : "A label and its accompanying text displayed in a lock screen panel.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|
@ -20125,7 +20293,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"With %lld graphs enabled, the Atoll 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." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Write" : {
|
"Write" : {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ protocol CalendarServiceProviding {
|
||||||
func requestAccess(to type: EKEntityType) async -> Bool
|
func requestAccess(to type: EKEntityType) async -> Bool
|
||||||
func calendars() async -> [CalendarModel]
|
func calendars() async -> [CalendarModel]
|
||||||
func events(from start: Date, to end: Date, calendars: [String]) async -> [EventModel]
|
func events(from start: Date, to end: Date, calendars: [String]) async -> [EventModel]
|
||||||
|
func setReminderCompleted(reminderID: String, completed: Bool) async
|
||||||
}
|
}
|
||||||
|
|
||||||
class CalendarService: CalendarServiceProviding {
|
class CalendarService: CalendarServiceProviding {
|
||||||
|
|
@ -122,6 +123,17 @@ class CalendarService: CalendarServiceProviding {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func setReminderCompleted(reminderID: String, completed: Bool) async {
|
||||||
|
guard let reminder = store.calendarItem(withIdentifier: reminderID) as? EKReminder else { return }
|
||||||
|
reminder.isCompleted = completed
|
||||||
|
do {
|
||||||
|
try store.save(reminder, commit: true)
|
||||||
|
} catch {
|
||||||
|
print("Failed to update reminder completion: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Model Extensions
|
// MARK: - Model Extensions
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,12 @@ import Defaults
|
||||||
import EventKit
|
import EventKit
|
||||||
|
|
||||||
struct Config: Equatable {
|
struct Config: Equatable {
|
||||||
// var count: Int = 10 // 3 days past + today + 7 days future
|
var past: Int = 7
|
||||||
var past: Int = 3
|
var future: Int = 14
|
||||||
var future: Int = 7
|
var steps: Int = 1
|
||||||
var steps: Int = 1 // Each step is one day
|
var spacing: CGFloat = 0
|
||||||
var spacing: CGFloat = 1
|
|
||||||
var showsText: Bool = true
|
var showsText: Bool = true
|
||||||
var offset: Int = 2 // Number of dates to the left of the selected date
|
var offset: Int = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WheelPicker: View {
|
struct WheelPicker: View {
|
||||||
|
|
@ -30,22 +29,24 @@ struct WheelPicker: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: config.spacing) {
|
HStack(spacing: config.spacing) {
|
||||||
let totalSteps = config.steps * (config.past + config.future)
|
|
||||||
let spacerNum = config.offset
|
let spacerNum = config.offset
|
||||||
ForEach(0..<totalSteps + 2 * spacerNum + 1, id: \.self) { index in
|
let dateCount = totalDateItems()
|
||||||
if(index < spacerNum || index > totalSteps + spacerNum - 1){
|
let totalItems = dateCount + 2 * spacerNum
|
||||||
Spacer().frame(width: 24, height: 24).id(index)
|
ForEach(0..<totalItems, id: \.self) { index in
|
||||||
|
if index < spacerNum || index >= spacerNum + dateCount {
|
||||||
|
Spacer()
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.id(index)
|
||||||
} else {
|
} else {
|
||||||
let offset = -config.offset - config.past
|
let date = dateForItemIndex(index: index, spacerNum: spacerNum)
|
||||||
let date = dateForIndex(index, offset: offset)
|
let isSelected = Calendar.current.isDate(date, inSameDayAs: selectedDate)
|
||||||
let isSelected = isDateSelected(index, offset: offset)
|
dateButton(date: date, isSelected: isSelected, id: index) {
|
||||||
dateButton(date: date, isSelected: isSelected, offset: offset){
|
|
||||||
selectedDate = date
|
selectedDate = date
|
||||||
byClick = true
|
byClick = true
|
||||||
withAnimation{
|
withAnimation {
|
||||||
scrollPosition = indexForDate(date, offset: offset) - config.offset
|
scrollPosition = index
|
||||||
}
|
}
|
||||||
if (Defaults[.enableHaptics]) {
|
if Defaults[.enableHaptics] {
|
||||||
haptics.toggle()
|
haptics.toggle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -56,85 +57,109 @@ struct WheelPicker: View {
|
||||||
.scrollTargetLayout()
|
.scrollTargetLayout()
|
||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
.scrollPosition(id: $scrollPosition, anchor: .leading)
|
.scrollPosition(id: $scrollPosition, anchor: .center)
|
||||||
.scrollTargetBehavior(.viewAligned) // Ensures scroll view snaps to button center
|
.scrollTargetBehavior(.viewAligned)
|
||||||
.safeAreaPadding(.horizontal)
|
.safeAreaPadding(.horizontal)
|
||||||
.sensoryFeedback(.alignment, trigger: haptics)
|
.sensoryFeedback(.alignment, trigger: haptics)
|
||||||
.onChange(of: scrollPosition) { oldValue, newValue in
|
.onChange(of: scrollPosition) { _, newValue in
|
||||||
if(!byClick){
|
if !byClick {
|
||||||
handleScrollChange(newValue: newValue, config: config)
|
handleScrollChange(newValue: newValue, config: config)
|
||||||
}else{
|
} else {
|
||||||
byClick = false
|
byClick = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
scrollToToday(config: config)
|
scrollToToday(config: config)
|
||||||
}
|
}
|
||||||
}
|
.onChange(of: selectedDate) { _, newValue in
|
||||||
|
let targetIndex = indexForDate(newValue)
|
||||||
private func dateButton(date: Date, isSelected: Bool, offset: Int, onClick:@escaping()->Void) -> some View {
|
if scrollPosition != targetIndex {
|
||||||
Button(action: onClick) {
|
byClick = true
|
||||||
VStack(spacing: 2) {
|
withAnimation {
|
||||||
dayText(date: dateToString(for: date), isSelected: isSelected)
|
scrollPosition = targetIndex
|
||||||
dateCircle(date: date, isSelected: isSelected)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dateButton(date: Date, isSelected: Bool, id: Int, onClick: @escaping () -> Void) -> some View {
|
||||||
|
let isToday = Calendar.current.isDateInToday(date)
|
||||||
|
return Button(action: onClick) {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
dayText(date: dateToString(for: date), isToday: isToday, isSelected: isSelected)
|
||||||
|
dateCircle(date: date, isToday: isToday, isSelected: isSelected)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.background(isSelected ? Color.effectiveAccentBackground : Color.clear)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
.id(indexForDate(date, offset: offset))
|
.id(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dayText(date: String, isSelected: Bool) -> some View {
|
private func dayText(date: String, isToday: Bool, isSelected: Bool) -> some View {
|
||||||
Text(date)
|
Text(date)
|
||||||
.font(.caption2)
|
.font(.caption)
|
||||||
.foregroundStyle(isSelected ? Color.accentColor : .gray)
|
.foregroundColor(isSelected ? .white : Color(white: 0.65))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dateCircle(date: Date, isSelected: Bool) -> some View {
|
private func dateCircle(date: Date, isToday: Bool, isSelected: Bool) -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(isSelected ? Color.accentColor : .clear)
|
.fill(isToday ? Color.effectiveAccent : .clear)
|
||||||
.frame(width: 24, height: 24)
|
.frame(width: 20, height: 20)
|
||||||
Text("\(date.date)")
|
Text(date.date)
|
||||||
.font(.title3)
|
.font(.body)
|
||||||
.foregroundStyle(isSelected ? .white : .gray)
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(isSelected ? .white : Color(white: isToday ? 0.9 : 0.65))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleScrollChange(newValue: Int?, config: Config) {
|
func handleScrollChange(newValue: Int?, config: Config) {
|
||||||
let offset = -config.offset - config.past
|
|
||||||
let todayIndex = indexForDate(Date(), offset: offset)
|
|
||||||
guard let newIndex = newValue else { return }
|
guard let newIndex = newValue else { return }
|
||||||
let targetDateIndex = newIndex + config.offset
|
let spacerNum = config.offset
|
||||||
switch targetDateIndex {
|
let dateCount = totalDateItems()
|
||||||
case todayIndex - config.past ..< todayIndex + config.future:
|
guard (spacerNum..<(spacerNum + dateCount)).contains(newIndex) else { return }
|
||||||
selectedDate = dateForIndex(targetDateIndex, offset: offset)
|
let date = dateForItemIndex(index: newIndex, spacerNum: spacerNum)
|
||||||
if (Defaults[.enableHaptics]) {
|
if !Calendar.current.isDate(date, inSameDayAs: selectedDate) {
|
||||||
|
selectedDate = date
|
||||||
|
if Defaults[.enableHaptics] {
|
||||||
haptics.toggle()
|
haptics.toggle()
|
||||||
}
|
}
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scrollToToday(config: Config) {
|
private func scrollToToday(config: Config) {
|
||||||
let today = Date()
|
let today = Date()
|
||||||
let todayIndex = indexForDate(today, offset: -config.offset - config.past)
|
|
||||||
byClick = true
|
byClick = true
|
||||||
scrollPosition = todayIndex - config.offset
|
scrollPosition = indexForDate(today)
|
||||||
selectedDate = today
|
selectedDate = today
|
||||||
}
|
}
|
||||||
|
|
||||||
private func indexForDate(_ date: Date, offset: Int) -> Int {
|
private func indexForDate(_ date: Date) -> Int {
|
||||||
let calendar = Calendar.current
|
let spacerNum = config.offset
|
||||||
let startDate = calendar.startOfDay(for: calendar.date(byAdding: .day, value: offset, to: Date()) ?? Date())
|
let cal = Calendar.current
|
||||||
let targetDate = calendar.startOfDay(for: date)
|
let today = cal.startOfDay(for: Date())
|
||||||
let daysDifference = calendar.dateComponents([.day], from: startDate, to: targetDate).day ?? 0
|
let startDate = cal.startOfDay(for: cal.date(byAdding: .day, value: -config.past, to: today) ?? today)
|
||||||
return daysDifference
|
let target = cal.startOfDay(for: date)
|
||||||
|
let days = cal.dateComponents([.day], from: startDate, to: target).day ?? 0
|
||||||
|
let stepIndex = max(0, min(days / max(config.steps, 1), totalDateItems() - 1))
|
||||||
|
return spacerNum + stepIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dateForIndex(_ index: Int, offset: Int) -> Date {
|
private func dateForItemIndex(index: Int, spacerNum: Int) -> Date {
|
||||||
let startDate = Calendar.current.date(byAdding: .day, value: offset, to: Date()) ?? Date()
|
let cal = Calendar.current
|
||||||
return Calendar.current.date(byAdding: .day, value: index, to: startDate) ?? Date()
|
let today = cal.startOfDay(for: Date())
|
||||||
|
let startDate = cal.date(byAdding: .day, value: -config.past, to: today) ?? today
|
||||||
|
let stepIndex = index - spacerNum
|
||||||
|
return cal.date(byAdding: .day, value: stepIndex * max(config.steps, 1), to: startDate) ?? today
|
||||||
|
}
|
||||||
|
|
||||||
|
private func totalDateItems() -> Int {
|
||||||
|
let range = config.past + config.future
|
||||||
|
let step = max(config.steps, 1)
|
||||||
|
return Int(ceil(Double(range) / Double(step))) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dateToString(for date: Date) -> String {
|
private func dateToString(for date: Date) -> String {
|
||||||
|
|
@ -142,10 +167,6 @@ struct WheelPicker: View {
|
||||||
formatter.dateFormat = "E"
|
formatter.dateFormat = "E"
|
||||||
return formatter.string(from: date)
|
return formatter.string(from: date)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isDateSelected(_ index: Int, offset: Int) -> Bool {
|
|
||||||
Calendar.current.isDate(dateForIndex(index, offset: offset), inSameDayAs: selectedDate)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CalendarView: View {
|
struct CalendarView: View {
|
||||||
|
|
@ -187,7 +208,7 @@ struct CalendarView: View {
|
||||||
events: calendarManager.events
|
events: calendarManager.events
|
||||||
)
|
)
|
||||||
if filteredEvents.isEmpty {
|
if filteredEvents.isEmpty {
|
||||||
EmptyEventsView()
|
EmptyEventsView(selectedDate: selectedDate)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
} else {
|
} else {
|
||||||
EventListView(events: calendarManager.events)
|
EventListView(events: calendarManager.events)
|
||||||
|
|
@ -216,12 +237,14 @@ struct CalendarView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EmptyEventsView: View {
|
struct EmptyEventsView: View {
|
||||||
|
let selectedDate: Date
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
Image(systemName: "calendar.badge.checkmark")
|
Image(systemName: "calendar.badge.checkmark")
|
||||||
.font(.title)
|
.font(.title)
|
||||||
.foregroundColor(Color(white: 0.65))
|
.foregroundColor(Color(white: 0.65))
|
||||||
Text("No events today")
|
Text(Calendar.current.isDateInToday(selectedDate) ? "No events today" : "No events")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
Text("Enjoy your free time!")
|
Text("Enjoy your free time!")
|
||||||
|
|
@ -235,11 +258,16 @@ struct EventListView: View {
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
@ObservedObject private var calendarManager = CalendarManager.shared
|
@ObservedObject private var calendarManager = CalendarManager.shared
|
||||||
let events: [EventModel]
|
let events: [EventModel]
|
||||||
|
@Default(.autoScrollToNextEvent) private var autoScrollToNextEvent
|
||||||
|
@Default(.showFullEventTitles) private var showFullEventTitles
|
||||||
|
|
||||||
static func filteredEvents(events: [EventModel]) -> [EventModel] {
|
static func filteredEvents(events: [EventModel]) -> [EventModel] {
|
||||||
events.filter { event in
|
events.filter { event in
|
||||||
// Filter out all-day events if setting is enabled
|
if event.type.isReminder {
|
||||||
|
if case .reminder(let completed) = event.type {
|
||||||
|
return !completed || !Defaults[.hideCompletedReminders]
|
||||||
|
}
|
||||||
|
}
|
||||||
if event.isAllDay && Defaults[.hideAllDayEvents] {
|
if event.isAllDay && Defaults[.hideAllDayEvents] {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -251,42 +279,129 @@ struct EventListView: View {
|
||||||
Self.filteredEvents(events: events)
|
Self.filteredEvents(events: events)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
private func scrollToRelevantEvent(proxy: ScrollViewProxy) {
|
||||||
List {
|
guard autoScrollToNextEvent else { return }
|
||||||
ForEach(filteredEvents) { event in
|
let now = Date()
|
||||||
Button(action: {
|
let nonAllDayUpcoming = filteredEvents.first(where: { !$0.isAllDay && $0.end > now })
|
||||||
if let url = event.calendarAppURL() {
|
let firstAllDay = filteredEvents.first(where: { $0.isAllDay })
|
||||||
openURL(url)
|
let lastEvent = filteredEvents.last
|
||||||
}
|
guard let target = nonAllDayUpcoming ?? firstAllDay ?? lastEvent else { return }
|
||||||
}) {
|
|
||||||
eventRow(event)
|
Task { @MainActor in
|
||||||
}
|
withTransaction(Transaction(animation: nil)) {
|
||||||
.padding(.leading, -5)
|
proxy.scrollTo(target.id, anchor: .top)
|
||||||
.buttonStyle(PlainButtonStyle())
|
}
|
||||||
.listRowSeparator(.automatic)
|
}
|
||||||
.listRowSeparatorTint(.gray.opacity(0.2))
|
}
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
List {
|
||||||
|
ForEach(filteredEvents) { event in
|
||||||
|
Button(action: {
|
||||||
|
if let url = event.calendarAppURL() {
|
||||||
|
openURL(url)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
eventRow(event)
|
||||||
|
}
|
||||||
|
.id(event.id)
|
||||||
|
.padding(.leading, -5)
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.listRowSeparator(.automatic)
|
||||||
|
.listRowSeparatorTint(.gray.opacity(0.2))
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollIndicators(.never)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(Color.clear)
|
||||||
|
.onAppear {
|
||||||
|
scrollToRelevantEvent(proxy: proxy)
|
||||||
|
}
|
||||||
|
.onChange(of: filteredEvents) { _, _ in
|
||||||
|
scrollToRelevantEvent(proxy: proxy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollIndicators(.never)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.background(Color.clear)
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func eventRow(_ event: EventModel) -> some View {
|
private func eventRow(_ event: EventModel) -> some View {
|
||||||
// Regular event row
|
if event.type.isReminder {
|
||||||
return AnyView(
|
let isCompleted: Bool
|
||||||
HStack(spacing: 8) {
|
if case .reminder(let completed) = event.type {
|
||||||
Circle()
|
isCompleted = completed
|
||||||
.fill(Color(event.calendar.color))
|
} else {
|
||||||
.frame(width: 8, height: 8)
|
isCompleted = false
|
||||||
HStack {
|
}
|
||||||
Text(event.title)
|
return AnyView(
|
||||||
.font(.callout)
|
HStack(spacing: 8) {
|
||||||
.foregroundColor(.white)
|
ReminderToggle(
|
||||||
.lineLimit(1)
|
isOn: Binding(
|
||||||
|
get: { isCompleted },
|
||||||
|
set: { newValue in
|
||||||
|
Task {
|
||||||
|
await calendarManager.setReminderCompleted(
|
||||||
|
reminderID: event.id, completed: newValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
color: Color(event.calendar.color)
|
||||||
|
)
|
||||||
|
.opacity(1.0)
|
||||||
|
HStack {
|
||||||
|
Text(event.title)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(showFullEventTitles ? nil : 1)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
if event.isAllDay {
|
||||||
|
Text("All-day")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
} else {
|
||||||
|
Text(event.start, style: .time)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.opacity(
|
||||||
|
isCompleted
|
||||||
|
? 0.4
|
||||||
|
: event.start < Date.now && Calendar.current.isDateInToday(event.start)
|
||||||
|
? 0.6 : 1.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return AnyView(
|
||||||
|
HStack(alignment: .top, spacing: 4) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color(event.calendar.color))
|
||||||
|
.frame(width: 3)
|
||||||
|
.cornerRadius(1.5)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(event.title)
|
||||||
|
.font(.callout)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(showFullEventTitles ? nil : 2)
|
||||||
|
|
||||||
|
if let location = event.location, !location.isEmpty {
|
||||||
|
Text(location)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(Color(white: 0.65))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
VStack(alignment: .trailing, spacing: 4) {
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
if event.isAllDay {
|
if event.isAllDay {
|
||||||
|
|
@ -296,21 +411,48 @@ struct EventListView: View {
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
} else {
|
} else {
|
||||||
Text("\(event.start, style: .time)")
|
Text(event.start, style: .time)
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.lineLimit(1)
|
Text(event.end, style: .time)
|
||||||
Text("\(event.end, style: .time)")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(Color(white: 0.65))
|
.foregroundColor(Color(white: 0.65))
|
||||||
.lineLimit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.frame(minWidth: 44, alignment: .trailing)
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.opacity(
|
||||||
|
event.eventStatus == .ended && Calendar.current.isDateInToday(event.start)
|
||||||
|
? 0.6 : 1.0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReminderToggle: View {
|
||||||
|
@Binding var isOn: Bool
|
||||||
|
var color: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: {
|
||||||
|
isOn.toggle()
|
||||||
|
}) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(color, lineWidth: 2)
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
|
if isOn {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
}
|
||||||
|
Circle()
|
||||||
|
.fill(Color.black.opacity(0.001))
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.padding(0)
|
||||||
|
.accessibilityLabel(isOn ? "Mark as incomplete" : "Mark as complete")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
182
DynamicIsland/components/OSD/CustomOSDView.swift
Normal file
182
DynamicIsland/components/OSD/CustomOSDView.swift
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
import Defaults
|
||||||
|
|
||||||
|
/// Custom OSD panel that mimics macOS native OSD appearance
|
||||||
|
/// Supports volume, brightness, and keyboard backlight displays
|
||||||
|
struct CustomOSDView: View {
|
||||||
|
@Binding var type: SneakContentType
|
||||||
|
@Binding var value: CGFloat
|
||||||
|
@Binding var icon: String
|
||||||
|
|
||||||
|
@Default(.osdMaterial) var osdMaterial
|
||||||
|
@Default(.osdIconColorStyle) var osdIconColorStyle
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
private let iconSize: CGFloat = 56
|
||||||
|
private let progressBarHeight: CGFloat = 6
|
||||||
|
private let spacing: CGFloat = 20
|
||||||
|
private let segmentCount: Int = 16
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: spacing) {
|
||||||
|
// Icon
|
||||||
|
Image(systemName: symbolName)
|
||||||
|
.font(.system(size: iconSize, weight: .regular))
|
||||||
|
.foregroundStyle(iconColor)
|
||||||
|
.symbolRenderingMode(.hierarchical)
|
||||||
|
|
||||||
|
// Progress/Value Indicator
|
||||||
|
progressView
|
||||||
|
.frame(width: 140)
|
||||||
|
}
|
||||||
|
.frame(width: 200, height: 200)
|
||||||
|
.background(
|
||||||
|
backgroundView
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
|
.shadow(color: Color.black.opacity(0.25), radius: 24, x: 0, y: 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Symbol Logic
|
||||||
|
|
||||||
|
private var symbolName: String {
|
||||||
|
if !icon.isEmpty {
|
||||||
|
return icon
|
||||||
|
}
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .volume:
|
||||||
|
if value < 0.01 {
|
||||||
|
return "speaker.slash.fill"
|
||||||
|
} else if value < 0.33 {
|
||||||
|
return "speaker.fill"
|
||||||
|
} else if value < 0.66 {
|
||||||
|
return "speaker.wave.1.fill"
|
||||||
|
} else {
|
||||||
|
return "speaker.wave.3.fill"
|
||||||
|
}
|
||||||
|
|
||||||
|
case .brightness:
|
||||||
|
return "sun.max.fill"
|
||||||
|
|
||||||
|
case .backlight:
|
||||||
|
return value >= 0.5 ? "light.max" : "light.min"
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "questionmark"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconColor: Color {
|
||||||
|
switch osdIconColorStyle {
|
||||||
|
case .white:
|
||||||
|
return .white
|
||||||
|
case .whiteTransparent:
|
||||||
|
return .white.opacity(0.85)
|
||||||
|
case .lightGray:
|
||||||
|
return Color(white: 0.85)
|
||||||
|
case .darkGray:
|
||||||
|
return Color(white: 0.3)
|
||||||
|
case .black:
|
||||||
|
return .black
|
||||||
|
case .blackTransparent:
|
||||||
|
return .black.opacity(0.85)
|
||||||
|
case .auto:
|
||||||
|
return colorScheme == .dark ? .white : .black
|
||||||
|
case .autoTransparent:
|
||||||
|
return colorScheme == .dark ? .white.opacity(0.85) : .black.opacity(0.85)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var textColor: Color {
|
||||||
|
switch osdIconColorStyle {
|
||||||
|
case .white, .whiteTransparent, .lightGray:
|
||||||
|
return .white
|
||||||
|
case .darkGray, .black, .blackTransparent:
|
||||||
|
return .black
|
||||||
|
case .auto, .autoTransparent:
|
||||||
|
return colorScheme == .dark ? .white : .black
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress View
|
||||||
|
|
||||||
|
private var progressView: some View {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
ForEach(0..<segmentCount, id: \.self) { index in
|
||||||
|
Capsule()
|
||||||
|
.fill(index < Int(value * CGFloat(segmentCount)) ? iconColor : inactiveSegmentColor)
|
||||||
|
.frame(width: 6, height: progressBarHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inactiveSegmentColor: Color {
|
||||||
|
switch osdIconColorStyle {
|
||||||
|
case .white, .whiteTransparent, .lightGray:
|
||||||
|
return Color.white.opacity(0.25)
|
||||||
|
case .darkGray, .black, .blackTransparent:
|
||||||
|
return Color.black.opacity(0.25)
|
||||||
|
case .auto, .autoTransparent:
|
||||||
|
return colorScheme == .dark ? Color.white.opacity(0.25) : Color.black.opacity(0.25)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Background
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var backgroundView: some View {
|
||||||
|
switch osdMaterial {
|
||||||
|
case .frosted:
|
||||||
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
case .liquid:
|
||||||
|
if #available(macOS 26.0, *) {
|
||||||
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
|
.glassEffect(
|
||||||
|
.clear.interactive(),
|
||||||
|
in: .rect(cornerRadius: 18)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
}
|
||||||
|
case .solidDark:
|
||||||
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
|
.fill(Color.black.opacity(0.85))
|
||||||
|
case .solidLight:
|
||||||
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
|
.fill(Color.white.opacity(0.85))
|
||||||
|
case .solidAuto:
|
||||||
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
|
.fill((colorScheme == .dark ? Color.black : Color.white).opacity(0.85))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OSD Material Enum
|
||||||
|
|
||||||
|
enum OSDMaterial: String, CaseIterable, Defaults.Serializable {
|
||||||
|
case frosted = "Frosted Glass"
|
||||||
|
case liquid = "Liquid Glass"
|
||||||
|
case solidDark = "Solid Dark"
|
||||||
|
case solidLight = "Solid Light"
|
||||||
|
case solidAuto = "Solid Auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OSD Icon Color Style Enum
|
||||||
|
|
||||||
|
enum OSDIconColorStyle: String, CaseIterable, Defaults.Serializable {
|
||||||
|
case white = "White"
|
||||||
|
case whiteTransparent = "White (Transparent)"
|
||||||
|
case lightGray = "Light Gray"
|
||||||
|
case darkGray = "Dark Gray"
|
||||||
|
case black = "Black"
|
||||||
|
case blackTransparent = "Black (Transparent)"
|
||||||
|
case auto = "Auto"
|
||||||
|
case autoTransparent = "Auto (Transparent)"
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -24,6 +24,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
||||||
case timer
|
case timer
|
||||||
case calendar
|
case calendar
|
||||||
case hud
|
case hud
|
||||||
|
case osd
|
||||||
case battery
|
case battery
|
||||||
case stats
|
case stats
|
||||||
case clipboard
|
case clipboard
|
||||||
|
|
@ -46,6 +47,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
||||||
case .timer: return "Timer"
|
case .timer: return "Timer"
|
||||||
case .calendar: return "Calendar"
|
case .calendar: return "Calendar"
|
||||||
case .hud: return "HUDs"
|
case .hud: return "HUDs"
|
||||||
|
case .osd: return "Custom OSD"
|
||||||
case .battery: return "Battery"
|
case .battery: return "Battery"
|
||||||
case .stats: return "Stats"
|
case .stats: return "Stats"
|
||||||
case .clipboard: return "Clipboard"
|
case .clipboard: return "Clipboard"
|
||||||
|
|
@ -68,6 +70,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
||||||
case .timer: return "timer"
|
case .timer: return "timer"
|
||||||
case .calendar: return "calendar"
|
case .calendar: return "calendar"
|
||||||
case .hud: return "dial.medium.fill"
|
case .hud: return "dial.medium.fill"
|
||||||
|
case .osd: return "square.fill.on.square.fill"
|
||||||
case .battery: return "battery.100.bolt"
|
case .battery: return "battery.100.bolt"
|
||||||
case .stats: return "chart.xyaxis.line"
|
case .stats: return "chart.xyaxis.line"
|
||||||
case .clipboard: return "clipboard"
|
case .clipboard: return "clipboard"
|
||||||
|
|
@ -90,6 +93,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
||||||
case .timer: return .red
|
case .timer: return .red
|
||||||
case .calendar: return .cyan
|
case .calendar: return .cyan
|
||||||
case .hud: return .indigo
|
case .hud: return .indigo
|
||||||
|
case .osd: return .teal
|
||||||
case .battery: return .yellow
|
case .battery: return .yellow
|
||||||
case .stats: return .teal
|
case .stats: return .teal
|
||||||
case .clipboard: return .mint
|
case .clipboard: return .mint
|
||||||
|
|
@ -257,6 +261,18 @@ struct SettingsView: View {
|
||||||
sidebarIcon(for: tab)
|
sidebarIcon(for: tab)
|
||||||
Text(tab.title)
|
Text(tab.title)
|
||||||
.foregroundStyle(Color.primary)
|
.foregroundStyle(Color.primary)
|
||||||
|
if tab == .osd {
|
||||||
|
Spacer()
|
||||||
|
Text("ALPHA")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.orange)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
|
@ -352,6 +368,7 @@ struct SettingsView: View {
|
||||||
.timer,
|
.timer,
|
||||||
.calendar,
|
.calendar,
|
||||||
.hud,
|
.hud,
|
||||||
|
.osd,
|
||||||
.battery,
|
.battery,
|
||||||
.stats,
|
.stats,
|
||||||
.clipboard,
|
.clipboard,
|
||||||
|
|
@ -536,7 +553,7 @@ struct SettingsView: View {
|
||||||
SettingsSearchEntry(tab: .general, title: "Show on all displays", keywords: ["multi-display", "external monitor"], highlightID: SettingsTab.general.highlightID(for: "Show on all displays")),
|
SettingsSearchEntry(tab: .general, title: "Show on all displays", keywords: ["multi-display", "external monitor"], highlightID: SettingsTab.general.highlightID(for: "Show on all displays")),
|
||||||
SettingsSearchEntry(tab: .general, title: "Show on a specific display", keywords: ["preferred screen", "display picker"], highlightID: SettingsTab.general.highlightID(for: "Show on a specific display")),
|
SettingsSearchEntry(tab: .general, title: "Show on a specific display", keywords: ["preferred screen", "display picker"], highlightID: SettingsTab.general.highlightID(for: "Show on a specific display")),
|
||||||
SettingsSearchEntry(tab: .general, title: "Automatically switch displays", keywords: ["auto switch", "displays"], highlightID: SettingsTab.general.highlightID(for: "Automatically switch displays")),
|
SettingsSearchEntry(tab: .general, title: "Automatically switch displays", keywords: ["auto switch", "displays"], highlightID: SettingsTab.general.highlightID(for: "Automatically switch displays")),
|
||||||
SettingsSearchEntry(tab: .general, title: "Hide Atoll during screenshots & recordings", keywords: ["privacy", "screenshot", "recording"], highlightID: SettingsTab.general.highlightID(for: "Hide Atoll during screenshots & recordings")),
|
SettingsSearchEntry(tab: .general, title: "Hide Dynamic Island during screenshots & recordings", keywords: ["privacy", "screenshot", "recording"], highlightID: SettingsTab.general.highlightID(for: "Hide Dynamic Island during screenshots & recordings")),
|
||||||
SettingsSearchEntry(tab: .general, title: "Enable Screen Recording Detection", keywords: ["screen recording", "indicator"], highlightID: SettingsTab.general.highlightID(for: "Enable Screen Recording Detection")),
|
SettingsSearchEntry(tab: .general, title: "Enable Screen Recording Detection", keywords: ["screen recording", "indicator"], highlightID: SettingsTab.general.highlightID(for: "Enable Screen Recording Detection")),
|
||||||
SettingsSearchEntry(tab: .general, title: "Show Recording Indicator", keywords: ["recording indicator", "red dot"], highlightID: SettingsTab.general.highlightID(for: "Show Recording Indicator")),
|
SettingsSearchEntry(tab: .general, title: "Show Recording Indicator", keywords: ["recording indicator", "red dot"], highlightID: SettingsTab.general.highlightID(for: "Show Recording Indicator")),
|
||||||
SettingsSearchEntry(tab: .general, title: "Enable Focus Detection", keywords: ["focus", "do not disturb", "dnd"], highlightID: SettingsTab.general.highlightID(for: "Enable Focus Detection")),
|
SettingsSearchEntry(tab: .general, title: "Enable Focus Detection", keywords: ["focus", "do not disturb", "dnd"], highlightID: SettingsTab.general.highlightID(for: "Enable Focus Detection")),
|
||||||
|
|
@ -577,6 +594,14 @@ struct SettingsView: View {
|
||||||
SettingsSearchEntry(tab: .hud, title: "Enable glowing effect", keywords: ["glow", "indicator"], highlightID: SettingsTab.hud.highlightID(for: "Enable glowing effect")),
|
SettingsSearchEntry(tab: .hud, title: "Enable glowing effect", keywords: ["glow", "indicator"], highlightID: SettingsTab.hud.highlightID(for: "Enable glowing effect")),
|
||||||
SettingsSearchEntry(tab: .hud, title: "Use accent color", keywords: ["accent", "color"], highlightID: SettingsTab.hud.highlightID(for: "Use accent color")),
|
SettingsSearchEntry(tab: .hud, title: "Use accent color", keywords: ["accent", "color"], highlightID: SettingsTab.hud.highlightID(for: "Use accent color")),
|
||||||
|
|
||||||
|
// Custom OSD
|
||||||
|
SettingsSearchEntry(tab: .osd, title: "Enable Custom OSD", keywords: ["osd", "on-screen display", "custom osd"], highlightID: SettingsTab.osd.highlightID(for: "Enable Custom OSD")),
|
||||||
|
SettingsSearchEntry(tab: .osd, title: "Volume OSD", keywords: ["volume", "osd"], highlightID: SettingsTab.osd.highlightID(for: "Volume OSD")),
|
||||||
|
SettingsSearchEntry(tab: .osd, title: "Brightness OSD", keywords: ["brightness", "osd"], highlightID: SettingsTab.osd.highlightID(for: "Brightness OSD")),
|
||||||
|
SettingsSearchEntry(tab: .osd, title: "Keyboard Backlight OSD", keywords: ["keyboard", "backlight", "osd"], highlightID: SettingsTab.osd.highlightID(for: "Keyboard Backlight OSD")),
|
||||||
|
SettingsSearchEntry(tab: .osd, title: "Material", keywords: ["material", "frosted", "liquid", "glass", "solid", "osd"], highlightID: SettingsTab.osd.highlightID(for: "Material")),
|
||||||
|
SettingsSearchEntry(tab: .osd, title: "Icon & Progress Color", keywords: ["color", "icon", "white", "black", "gray", "osd"], highlightID: SettingsTab.osd.highlightID(for: "Icon & Progress Color")),
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
SettingsSearchEntry(tab: .media, title: "Music Source", keywords: ["media source", "controller"], highlightID: SettingsTab.media.highlightID(for: "Music Source")),
|
SettingsSearchEntry(tab: .media, title: "Music Source", keywords: ["media source", "controller"], highlightID: SettingsTab.media.highlightID(for: "Music Source")),
|
||||||
SettingsSearchEntry(tab: .media, title: "Skip buttons", keywords: ["skip", "controls", "±10"], highlightID: SettingsTab.media.highlightID(for: "Skip buttons")),
|
SettingsSearchEntry(tab: .media, title: "Skip buttons", keywords: ["skip", "controls", "±10"], highlightID: SettingsTab.media.highlightID(for: "Skip buttons")),
|
||||||
|
|
@ -589,6 +614,10 @@ struct SettingsView: View {
|
||||||
SettingsSearchEntry(tab: .calendar, title: "Countdown style", keywords: ["reminder countdown"], highlightID: SettingsTab.calendar.highlightID(for: "Countdown style")),
|
SettingsSearchEntry(tab: .calendar, title: "Countdown style", keywords: ["reminder countdown"], highlightID: SettingsTab.calendar.highlightID(for: "Countdown style")),
|
||||||
SettingsSearchEntry(tab: .calendar, title: "Show lock screen reminder", keywords: ["lock screen", "reminder widget"], highlightID: SettingsTab.calendar.highlightID(for: "Show lock screen reminder")),
|
SettingsSearchEntry(tab: .calendar, title: "Show lock screen reminder", keywords: ["lock screen", "reminder widget"], highlightID: SettingsTab.calendar.highlightID(for: "Show lock screen reminder")),
|
||||||
SettingsSearchEntry(tab: .calendar, title: "Chip color", keywords: ["reminder chip", "color"], highlightID: SettingsTab.calendar.highlightID(for: "Chip color")),
|
SettingsSearchEntry(tab: .calendar, title: "Chip color", keywords: ["reminder chip", "color"], highlightID: SettingsTab.calendar.highlightID(for: "Chip color")),
|
||||||
|
SettingsSearchEntry(tab: .calendar, title: "Hide all-day events", keywords: ["calendar", "all-day"], highlightID: SettingsTab.calendar.highlightID(for: "Hide all-day events")),
|
||||||
|
SettingsSearchEntry(tab: .calendar, title: "Hide completed reminders", keywords: ["reminder", "completed"], highlightID: SettingsTab.calendar.highlightID(for: "Hide completed reminders")),
|
||||||
|
SettingsSearchEntry(tab: .calendar, title: "Show full event titles", keywords: ["calendar", "titles"], highlightID: SettingsTab.calendar.highlightID(for: "Show full event titles")),
|
||||||
|
SettingsSearchEntry(tab: .calendar, title: "Auto-scroll to next event", keywords: ["calendar", "scroll"], highlightID: SettingsTab.calendar.highlightID(for: "Auto-scroll to next event")),
|
||||||
|
|
||||||
// Shelf
|
// Shelf
|
||||||
SettingsSearchEntry(tab: .shelf, title: "Enable shelf", keywords: ["shelf", "dock"], highlightID: SettingsTab.shelf.highlightID(for: "Enable shelf")),
|
SettingsSearchEntry(tab: .shelf, title: "Enable shelf", keywords: ["shelf", "dock"], highlightID: SettingsTab.shelf.highlightID(for: "Enable shelf")),
|
||||||
|
|
@ -713,6 +742,28 @@ struct SettingsView: View {
|
||||||
SettingsForm(tab: .hud) {
|
SettingsForm(tab: .hud) {
|
||||||
HUD()
|
HUD()
|
||||||
}
|
}
|
||||||
|
case .osd:
|
||||||
|
SettingsForm(tab: .osd) {
|
||||||
|
if #available(macOS 15.0, *) {
|
||||||
|
CustomOSDSettings()
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
|
||||||
|
Text("macOS 15 or later required")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text("Custom OSD feature requires macOS 15 or later.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
case .battery:
|
case .battery:
|
||||||
SettingsForm(tab: .battery) {
|
SettingsForm(tab: .battery) {
|
||||||
Charge()
|
Charge()
|
||||||
|
|
@ -839,8 +890,8 @@ struct GeneralSettings: View {
|
||||||
}
|
}
|
||||||
.disabled(showOnAllDisplays)
|
.disabled(showOnAllDisplays)
|
||||||
.settingsHighlight(id: highlightID("Automatically switch displays"))
|
.settingsHighlight(id: highlightID("Automatically switch displays"))
|
||||||
Defaults.Toggle("Hide Atoll during screenshots & recordings", key: .hideDynamicIslandFromScreenCapture)
|
Defaults.Toggle("Hide Dynamic Island during screenshots & recordings", key: .hideDynamicIslandFromScreenCapture)
|
||||||
.settingsHighlight(id: highlightID("Hide Atoll during screenshots & recordings"))
|
.settingsHighlight(id: highlightID("Hide Dynamic Island during screenshots & recordings"))
|
||||||
} header: {
|
} header: {
|
||||||
Text("System features")
|
Text("System features")
|
||||||
}
|
}
|
||||||
|
|
@ -1231,56 +1282,60 @@ struct HUD: View {
|
||||||
@Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD
|
@Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD
|
||||||
@Default(.systemHUDSensitivity) var systemHUDSensitivity
|
@Default(.systemHUDSensitivity) var systemHUDSensitivity
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
|
@ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared
|
||||||
|
|
||||||
private func highlightID(_ title: String) -> String {
|
private func highlightID(_ title: String) -> String {
|
||||||
SettingsTab.hud.highlightID(for: title)
|
SettingsTab.hud.highlightID(for: title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var hasAccessibilityPermission: Bool {
|
||||||
|
accessibilityPermission.isAuthorized
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section {
|
if !hasAccessibilityPermission {
|
||||||
Toggle("Enable HUD replacement", isOn: $coordinator.hudReplacement)
|
Section {
|
||||||
} header: {
|
SettingsPermissionCallout(
|
||||||
Text("General")
|
message: "Accessibility permission lets Dynamic Island replace the native volume, brightness, and keyboard HUDs.",
|
||||||
} footer: {
|
requestAction: { accessibilityPermission.requestAuthorizationPrompt() },
|
||||||
Text("Replaces system HUD notifications with Atoll displays.")
|
openSettingsAction: { accessibilityPermission.openSystemSettings() }
|
||||||
.foregroundStyle(.secondary)
|
)
|
||||||
.font(.caption)
|
} header: {
|
||||||
|
Text("Accessibility")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle("Enable Built-in System HUD", isOn: $enableSystemHUD)
|
Toggle("Enable HUDs", isOn: $enableSystemHUD)
|
||||||
|
.disabled(Defaults[.enableCustomOSD] || !hasAccessibilityPermission)
|
||||||
if enableSystemHUD {
|
|
||||||
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
|
||||||
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
|
||||||
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text("Sensitivity")
|
|
||||||
Spacer()
|
|
||||||
Slider(value: Binding(
|
|
||||||
get: { Double(systemHUDSensitivity) },
|
|
||||||
set: { systemHUDSensitivity = Int($0) }
|
|
||||||
), in: 1...10, step: 1) {
|
|
||||||
Text("Sensitivity")
|
|
||||||
}
|
|
||||||
.frame(width: 120)
|
|
||||||
Text("\(systemHUDSensitivity)")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.font(.caption)
|
|
||||||
.frame(width: 20)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
} header: {
|
||||||
Text("Built-in System Monitoring")
|
Text("General")
|
||||||
} footer: {
|
} footer: {
|
||||||
if enableSystemHUD {
|
if Defaults[.enableCustomOSD] {
|
||||||
Text("Built-in system monitoring detects volume, brightness, and keyboard backlight changes directly without requiring external apps.")
|
Text("HUDs are disabled because Custom OSD is enabled. Disable Custom OSD in the Custom OSD tab to use HUDs.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
} else if !hasAccessibilityPermission {
|
||||||
|
Text("Grant Accessibility permission in System Settings to control macOS HUD replacements from here.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
} else {
|
} else {
|
||||||
Text("Enable built-in system monitoring to replace macOS HUD notifications with Atoll displays.")
|
Text("Replaces macOS system HUD with Dynamic Island displays for volume, brightness, and keyboard backlight.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if enableSystemHUD && !Defaults[.enableCustomOSD] && hasAccessibilityPermission {
|
||||||
|
Section {
|
||||||
|
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
||||||
|
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
||||||
|
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
||||||
|
} header: {
|
||||||
|
Text("Controls")
|
||||||
|
} footer: {
|
||||||
|
Text("Choose which system controls should display HUD notifications.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
|
|
@ -1373,6 +1428,14 @@ struct HUD: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("HUDs")
|
.navigationTitle("HUDs")
|
||||||
|
.onAppear {
|
||||||
|
accessibilityPermission.refreshStatus()
|
||||||
|
}
|
||||||
|
.onChange(of: accessibilityPermission.isAuthorized) { _, granted in
|
||||||
|
if !granted {
|
||||||
|
enableSystemHUD = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1700,6 +1763,10 @@ struct CalendarSettings: View {
|
||||||
@Default(.reminderSneakPeekDuration) var reminderSneakPeekDuration
|
@Default(.reminderSneakPeekDuration) var reminderSneakPeekDuration
|
||||||
@Default(.enableLockScreenReminderWidget) var enableLockScreenReminderWidget
|
@Default(.enableLockScreenReminderWidget) var enableLockScreenReminderWidget
|
||||||
@Default(.lockScreenReminderChipStyle) var lockScreenReminderChipStyle
|
@Default(.lockScreenReminderChipStyle) var lockScreenReminderChipStyle
|
||||||
|
@Default(.hideAllDayEvents) var hideAllDayEvents
|
||||||
|
@Default(.hideCompletedReminders) var hideCompletedReminders
|
||||||
|
@Default(.showFullEventTitles) var showFullEventTitles
|
||||||
|
@Default(.autoScrollToNextEvent) var autoScrollToNextEvent
|
||||||
|
|
||||||
private func highlightID(_ title: String) -> String {
|
private func highlightID(_ title: String) -> String {
|
||||||
SettingsTab.calendar.highlightID(for: title)
|
SettingsTab.calendar.highlightID(for: title)
|
||||||
|
|
@ -1750,6 +1817,17 @@ struct CalendarSettings: View {
|
||||||
Defaults.Toggle("Show calendar", key: .showCalendar)
|
Defaults.Toggle("Show calendar", key: .showCalendar)
|
||||||
.settingsHighlight(id: highlightID("Show calendar"))
|
.settingsHighlight(id: highlightID("Show calendar"))
|
||||||
|
|
||||||
|
Section(header: Text("Event List")) {
|
||||||
|
Toggle("Hide all-day events", isOn: $hideAllDayEvents)
|
||||||
|
.settingsHighlight(id: highlightID("Hide all-day events"))
|
||||||
|
Toggle("Hide completed reminders", isOn: $hideCompletedReminders)
|
||||||
|
.settingsHighlight(id: highlightID("Hide completed reminders"))
|
||||||
|
Toggle("Show full event titles", isOn: $showFullEventTitles)
|
||||||
|
.settingsHighlight(id: highlightID("Show full event titles"))
|
||||||
|
Toggle("Auto-scroll to next event", isOn: $autoScrollToNextEvent)
|
||||||
|
.settingsHighlight(id: highlightID("Auto-scroll to next event"))
|
||||||
|
}
|
||||||
|
|
||||||
Section(header: Text("Reminder Live Activity")) {
|
Section(header: Text("Reminder Live Activity")) {
|
||||||
Defaults.Toggle("Enable reminder live activity", key: .enableReminderLiveActivity)
|
Defaults.Toggle("Enable reminder live activity", key: .enableReminderLiveActivity)
|
||||||
.settingsHighlight(id: highlightID("Enable reminder live activity"))
|
.settingsHighlight(id: highlightID("Enable reminder live activity"))
|
||||||
|
|
@ -2414,7 +2492,7 @@ struct LockScreenSettings: View {
|
||||||
} header: {
|
} header: {
|
||||||
Text("Live Activity & Feedback")
|
Text("Live Activity & Feedback")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Controls whether Atoll mirrors lock/unlock events with its own live activity and audible chimes.")
|
Text("Controls whether Dynamic Island mirrors lock/unlock events with its own live activity and audible chimes.")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
|
|
@ -3003,7 +3081,7 @@ struct Shortcuts: View {
|
||||||
} header: {
|
} header: {
|
||||||
Text("Navigation")
|
Text("Navigation")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Toggle the Atoll open or closed from anywhere.")
|
Text("Toggle the Dynamic Island open or closed from anywhere.")
|
||||||
.multilineTextAlignment(.trailing)
|
.multilineTextAlignment(.trailing)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|
@ -3777,7 +3855,7 @@ struct StatsSettings: View {
|
||||||
Text("Graph Visibility")
|
Text("Graph Visibility")
|
||||||
} footer: {
|
} footer: {
|
||||||
if enabledGraphsCount >= 4 {
|
if enabledGraphsCount >= 4 {
|
||||||
Text("With \(enabledGraphsCount) graphs enabled, the Atoll will expand horizontally to accommodate all graphs in a single row.")
|
Text("With \(enabledGraphsCount) graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row.")
|
||||||
.multilineTextAlignment(.trailing)
|
.multilineTextAlignment(.trailing)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|
@ -4360,6 +4438,240 @@ struct ColorPickerSettings: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CustomOSDSettings: View {
|
||||||
|
@Default(.enableCustomOSD) var enableCustomOSD
|
||||||
|
@Default(.hasSeenOSDAlphaWarning) var hasSeenOSDAlphaWarning
|
||||||
|
@Default(.enableOSDVolume) var enableOSDVolume
|
||||||
|
@Default(.enableOSDBrightness) var enableOSDBrightness
|
||||||
|
@Default(.enableOSDKeyboardBacklight) var enableOSDKeyboardBacklight
|
||||||
|
@Default(.osdMaterial) var osdMaterial
|
||||||
|
@Default(.osdIconColorStyle) var osdIconColorStyle
|
||||||
|
@Default(.enableSystemHUD) var enableSystemHUD
|
||||||
|
@ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared
|
||||||
|
|
||||||
|
@State private var showAlphaWarning = false
|
||||||
|
@State private var previewValue: CGFloat = 0.65
|
||||||
|
@State private var previewType: SneakContentType = .volume
|
||||||
|
|
||||||
|
private func highlightID(_ title: String) -> String {
|
||||||
|
SettingsTab.osd.highlightID(for: title)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasAccessibilityPermission: Bool {
|
||||||
|
accessibilityPermission.isAuthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
if !hasAccessibilityPermission {
|
||||||
|
Section {
|
||||||
|
SettingsPermissionCallout(
|
||||||
|
message: "Accessibility permission is needed to intercept system controls for the Custom OSD.",
|
||||||
|
requestAction: { accessibilityPermission.requestAuthorizationPrompt() },
|
||||||
|
openSettingsAction: { accessibilityPermission.openSystemSettings() }
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
Text("Accessibility")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("Enable Custom OSD", isOn: Binding(
|
||||||
|
get: { enableCustomOSD },
|
||||||
|
set: { newValue in
|
||||||
|
guard hasAccessibilityPermission else {
|
||||||
|
enableCustomOSD = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if newValue && !hasSeenOSDAlphaWarning {
|
||||||
|
showAlphaWarning = true
|
||||||
|
} else {
|
||||||
|
enableCustomOSD = newValue
|
||||||
|
if newValue {
|
||||||
|
// Disable System HUD when OSD is enabled
|
||||||
|
enableSystemHUD = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.settingsHighlight(id: highlightID("Enable Custom OSD"))
|
||||||
|
.disabled(enableSystemHUD || !hasAccessibilityPermission)
|
||||||
|
} header: {
|
||||||
|
Text("General")
|
||||||
|
} footer: {
|
||||||
|
if enableSystemHUD {
|
||||||
|
Text("Custom OSD is disabled because HUDs are enabled. Disable HUDs in the HUDs tab to use Custom OSD.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
} else if !hasAccessibilityPermission {
|
||||||
|
Text("Grant Accessibility permission in System Settings to customize the on-screen display replacements here.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
} else {
|
||||||
|
Text("Display custom macOS-style OSD windows at the bottom center of the screen when adjusting volume, brightness, or keyboard backlight.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if enableCustomOSD && hasAccessibilityPermission {
|
||||||
|
Section {
|
||||||
|
Toggle("Volume OSD", isOn: $enableOSDVolume)
|
||||||
|
.settingsHighlight(id: highlightID("Volume OSD"))
|
||||||
|
Toggle("Brightness OSD", isOn: $enableOSDBrightness)
|
||||||
|
.settingsHighlight(id: highlightID("Brightness OSD"))
|
||||||
|
Toggle("Keyboard Backlight OSD", isOn: $enableOSDKeyboardBacklight)
|
||||||
|
.settingsHighlight(id: highlightID("Keyboard Backlight OSD"))
|
||||||
|
} header: {
|
||||||
|
Text("Controls")
|
||||||
|
} footer: {
|
||||||
|
Text("Choose which system controls should display custom OSD windows.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("Material", selection: $osdMaterial) {
|
||||||
|
ForEach(OSDMaterial.allCases, id: \.self) { material in
|
||||||
|
Text(material.rawValue).tag(material)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Material"))
|
||||||
|
.onChange(of: osdMaterial) { _, _ in
|
||||||
|
previewValue = previewValue == 0.65 ? 0.651 : 0.65
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Icon & Progress Color", selection: $osdIconColorStyle) {
|
||||||
|
ForEach(OSDIconColorStyle.allCases, id: \.self) { style in
|
||||||
|
Text(style.rawValue).tag(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Icon & Progress Color"))
|
||||||
|
.onChange(of: osdIconColorStyle) { _, _ in
|
||||||
|
previewValue = previewValue == 0.65 ? 0.651 : 0.65
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Appearance")
|
||||||
|
} footer: {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Material Options:")
|
||||||
|
Text("• Frosted Glass: Translucent blur effect")
|
||||||
|
Text("• Liquid Glass: Modern glass effect (macOS 26+)")
|
||||||
|
Text("• Solid Dark/Light/Auto: Opaque backgrounds")
|
||||||
|
Text("")
|
||||||
|
Text("Color options control the icon and progress bar appearance. Auto adapts to system theme.")
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("Live Preview")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
CustomOSDView(
|
||||||
|
type: .constant(previewType),
|
||||||
|
value: .constant(previewValue),
|
||||||
|
icon: .constant("")
|
||||||
|
)
|
||||||
|
.frame(width: 200, height: 200)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button("Volume") {
|
||||||
|
previewType = .volume
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Button("Brightness") {
|
||||||
|
previewType = .brightness
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Button("Backlight") {
|
||||||
|
previewType = .backlight
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
|
||||||
|
Slider(value: $previewValue, in: 0...1)
|
||||||
|
.frame(width: 160)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Preview")
|
||||||
|
} footer: {
|
||||||
|
Text("Adjust settings above to see changes in real-time. The actual OSD appears at the bottom center of your screen.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Alpha Feature Warning", isPresented: $showAlphaWarning) {
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
enableCustomOSD = false
|
||||||
|
}
|
||||||
|
Button("Enable Anyway") {
|
||||||
|
hasSeenOSDAlphaWarning = true
|
||||||
|
enableCustomOSD = true
|
||||||
|
enableSystemHUD = false
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("Custom OSD is an experimental alpha feature and may contain bugs or unexpected behavior.\n\nThis feature requires macOS 15 or later and is still under active development. It's recommended to keep it disabled unless you want to help test it.")
|
||||||
|
}
|
||||||
|
.navigationTitle("Custom OSD")
|
||||||
|
.onAppear {
|
||||||
|
accessibilityPermission.refreshStatus()
|
||||||
|
}
|
||||||
|
.onChange(of: accessibilityPermission.isAuthorized) { _, granted in
|
||||||
|
if !granted {
|
||||||
|
enableCustomOSD = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SettingsPermissionCallout: View {
|
||||||
|
let message: String
|
||||||
|
let requestAction: () -> Void
|
||||||
|
let openSettingsAction: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label("Accessibility permission required", systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button("Request Access") {
|
||||||
|
requestAction()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
||||||
|
Button("Open Settings") {
|
||||||
|
openSettingsAction()
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.secondary.opacity(0.08))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
HUD()
|
HUD()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
DynamicIsland/extensions/Color+AccentColor.swift
Normal file
16
DynamicIsland/extensions/Color+AccentColor.swift
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
import Defaults
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
/// Returns the accent color configured in settings, falling back to the system accent color.
|
||||||
|
static var effectiveAccent: Color {
|
||||||
|
Defaults[.accentColor]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a subtle background variant of the accent color.
|
||||||
|
static var effectiveAccentBackground: Color {
|
||||||
|
Defaults[.accentColor].opacity(0.25)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
76
DynamicIsland/helpers/AccessibilityPermissionStore.swift
Normal file
76
DynamicIsland/helpers/AccessibilityPermissionStore.swift
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import AppKit
|
||||||
|
#if canImport(ApplicationServices)
|
||||||
|
import ApplicationServices
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Tracks accessibility permission status and exposes helpers to request access.
|
||||||
|
@MainActor
|
||||||
|
final class AccessibilityPermissionStore: ObservableObject {
|
||||||
|
static let shared = AccessibilityPermissionStore()
|
||||||
|
|
||||||
|
@Published private(set) var isAuthorized: Bool = AccessibilityPermissionStore.isAccessibilityAuthorized()
|
||||||
|
|
||||||
|
private var pollingTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
pollingTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshStatus() {
|
||||||
|
updateAuthorizationStatus(to: Self.isAccessibilityAuthorized())
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAuthorizationPrompt() {
|
||||||
|
#if canImport(ApplicationServices)
|
||||||
|
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
||||||
|
let options: CFDictionary = [promptKey: true] as CFDictionary
|
||||||
|
_ = AXIsProcessTrustedWithOptions(options)
|
||||||
|
#endif
|
||||||
|
beginPollingForStatusChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openSystemSettings() {
|
||||||
|
#if os(macOS)
|
||||||
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginPollingForStatusChanges() {
|
||||||
|
pollingTask?.cancel()
|
||||||
|
pollingTask = Task { [weak self] in
|
||||||
|
for _ in 0..<30 {
|
||||||
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
|
let status = Self.isAccessibilityAuthorized()
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self else { return }
|
||||||
|
self.updateAuthorizationStatus(to: status)
|
||||||
|
}
|
||||||
|
if status {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateAuthorizationStatus(to newValue: Bool) {
|
||||||
|
guard newValue != isAuthorized else { return }
|
||||||
|
isAuthorized = newValue
|
||||||
|
if !newValue {
|
||||||
|
beginPollingForStatusChanges()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isAccessibilityAuthorized() -> Bool {
|
||||||
|
#if canImport(ApplicationServices)
|
||||||
|
return AXIsProcessTrusted()
|
||||||
|
#else
|
||||||
|
return true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -203,4 +203,9 @@ class CalendarManager: ObservableObject {
|
||||||
)
|
)
|
||||||
self.events = eventsResult
|
self.events = eventsResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setReminderCompleted(reminderID: String, completed: Bool) async {
|
||||||
|
await calendarService.setReminderCompleted(reminderID: reminderID, completed: completed)
|
||||||
|
await updateEvents()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
252
DynamicIsland/managers/CustomOSDWindowManager.swift
Normal file
252
DynamicIsland/managers/CustomOSDWindowManager.swift
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
import SkyLightWindow
|
||||||
|
import QuartzCore
|
||||||
|
import Defaults
|
||||||
|
|
||||||
|
/// Manages custom OSD windows for volume, brightness, and keyboard backlight controls
|
||||||
|
/// Mimics macOS native OSD behavior with custom styling
|
||||||
|
@MainActor
|
||||||
|
final class CustomOSDWindowManager {
|
||||||
|
static let shared = CustomOSDWindowManager()
|
||||||
|
|
||||||
|
private var volumeWindow: OSDWindow?
|
||||||
|
private var brightnessWindow: OSDWindow?
|
||||||
|
private var backlightWindow: OSDWindow?
|
||||||
|
|
||||||
|
private var hideWorkItem: DispatchWorkItem?
|
||||||
|
private let displayDuration: TimeInterval = 2.0
|
||||||
|
private let animationDuration: TimeInterval = 0.3
|
||||||
|
private var isInitialized = false
|
||||||
|
|
||||||
|
// Standard macOS OSD dimensions (approximate)
|
||||||
|
private let osdWidth: CGFloat = 200
|
||||||
|
private let osdHeight: CGFloat = 200
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
func showVolume(value: CGFloat, isMuted: Bool = false, icon: String = "") {
|
||||||
|
guard Defaults[.enableCustomOSD], isInitialized else { return }
|
||||||
|
show(type: .volume, value: value, icon: icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showBrightness(value: CGFloat) {
|
||||||
|
guard Defaults[.enableCustomOSD], isInitialized else { return }
|
||||||
|
show(type: .brightness, value: value, icon: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func showBacklight(value: CGFloat) {
|
||||||
|
guard Defaults[.enableCustomOSD], isInitialized else { return }
|
||||||
|
show(type: .backlight, value: value, icon: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func initialize() {
|
||||||
|
isInitialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Implementation
|
||||||
|
|
||||||
|
private func show(type: SneakContentType, value: CGFloat, icon: String) {
|
||||||
|
guard let screen = NSScreen.main else { return }
|
||||||
|
|
||||||
|
// Close other windows first
|
||||||
|
hideAllWindowsExcept(type: type)
|
||||||
|
|
||||||
|
let window = ensureWindow(for: type)
|
||||||
|
updateContent(window: window, type: type, value: value, icon: icon)
|
||||||
|
|
||||||
|
let targetFrame = calculateFrame(for: screen)
|
||||||
|
|
||||||
|
if window.nsWindow.alphaValue <= 0.01 {
|
||||||
|
// Initial presentation
|
||||||
|
presentWindow(window, targetFrame: targetFrame)
|
||||||
|
} else {
|
||||||
|
// Update existing window
|
||||||
|
window.nsWindow.setFrame(targetFrame, display: true)
|
||||||
|
window.nsWindow.orderFrontRegardless()
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleHide(for: type)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureWindow(for type: SneakContentType) -> OSDWindow {
|
||||||
|
let window: OSDWindow
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .volume:
|
||||||
|
if let existing = volumeWindow {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
window = createWindow(for: type)
|
||||||
|
volumeWindow = window
|
||||||
|
case .brightness:
|
||||||
|
if let existing = brightnessWindow {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
window = createWindow(for: type)
|
||||||
|
brightnessWindow = window
|
||||||
|
case .backlight:
|
||||||
|
if let existing = backlightWindow {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
window = createWindow(for: type)
|
||||||
|
backlightWindow = window
|
||||||
|
default:
|
||||||
|
fatalError("Unsupported OSD type: \(type)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createWindow(for type: SneakContentType) -> OSDWindow {
|
||||||
|
guard let screen = NSScreen.main else {
|
||||||
|
fatalError("No main screen available")
|
||||||
|
}
|
||||||
|
|
||||||
|
let osdView = CustomOSDView(type: .constant(type), value: .constant(0), icon: .constant(""))
|
||||||
|
let hostingView = NSHostingView(rootView: osdView)
|
||||||
|
|
||||||
|
let frame = calculateFrame(for: screen)
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: frame,
|
||||||
|
styleMask: [.borderless, .nonactivatingPanel],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
window.isOpaque = false
|
||||||
|
window.backgroundColor = .clear
|
||||||
|
window.level = .statusBar + 1
|
||||||
|
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary]
|
||||||
|
window.hasShadow = false
|
||||||
|
window.contentView = hostingView
|
||||||
|
window.alphaValue = 0
|
||||||
|
|
||||||
|
// Delegate to SkyLight for proper rendering
|
||||||
|
SkyLightOperator.shared.delegateWindow(window)
|
||||||
|
|
||||||
|
return OSDWindow(nsWindow: window, hostingView: hostingView, type: type)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateContent(window: OSDWindow, type: SneakContentType, value: CGFloat, icon: String) {
|
||||||
|
let osdView = CustomOSDView(type: .constant(type), value: .constant(value), icon: .constant(icon))
|
||||||
|
window.hostingView.rootView = osdView
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateFrame(for screen: NSScreen) -> NSRect {
|
||||||
|
let screenFrame = screen.frame
|
||||||
|
let centerX = screenFrame.midX - (osdWidth / 2)
|
||||||
|
// Position at bottom center, similar to native macOS OSD
|
||||||
|
let bottomOffset: CGFloat = 120 // Distance from bottom of screen
|
||||||
|
let centerY = screenFrame.minY + bottomOffset
|
||||||
|
|
||||||
|
return NSRect(x: centerX, y: centerY, width: osdWidth, height: osdHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentWindow(_ window: OSDWindow, targetFrame: NSRect) {
|
||||||
|
let startFrame = targetFrame.offsetBy(dx: 0, dy: -20)
|
||||||
|
window.nsWindow.setFrame(startFrame, display: true)
|
||||||
|
window.nsWindow.alphaValue = 0
|
||||||
|
window.nsWindow.orderFrontRegardless()
|
||||||
|
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = animationDuration
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||||
|
window.nsWindow.animator().setFrame(targetFrame, display: true)
|
||||||
|
window.nsWindow.animator().alphaValue = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideAllWindowsExcept(type: SneakContentType) {
|
||||||
|
if type != .volume, let volumeWindow = volumeWindow, volumeWindow.nsWindow.alphaValue > 0.01 {
|
||||||
|
hideWindowImmediately(volumeWindow)
|
||||||
|
}
|
||||||
|
if type != .brightness, let brightnessWindow = brightnessWindow, brightnessWindow.nsWindow.alphaValue > 0.01 {
|
||||||
|
hideWindowImmediately(brightnessWindow)
|
||||||
|
}
|
||||||
|
if type != .backlight, let backlightWindow = backlightWindow, backlightWindow.nsWindow.alphaValue > 0.01 {
|
||||||
|
hideWindowImmediately(backlightWindow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideWindowImmediately(_ window: OSDWindow) {
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.15
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
||||||
|
window.nsWindow.animator().alphaValue = 0
|
||||||
|
} completionHandler: {
|
||||||
|
window.nsWindow.orderOut(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleHide(for type: SneakContentType) {
|
||||||
|
hideWorkItem?.cancel()
|
||||||
|
|
||||||
|
let workItem = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.hideWindow(for: type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideWorkItem = workItem
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + displayDuration, execute: workItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideWindow(for type: SneakContentType) {
|
||||||
|
let window: OSDWindow?
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .volume:
|
||||||
|
window = volumeWindow
|
||||||
|
case .brightness:
|
||||||
|
window = brightnessWindow
|
||||||
|
case .backlight:
|
||||||
|
window = backlightWindow
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let window else { return }
|
||||||
|
|
||||||
|
let currentFrame = window.nsWindow.frame
|
||||||
|
let hideFrame = currentFrame.offsetBy(dx: 0, dy: -20)
|
||||||
|
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = animationDuration
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
||||||
|
window.nsWindow.animator().setFrame(hideFrame, display: true)
|
||||||
|
window.nsWindow.animator().alphaValue = 0
|
||||||
|
} completionHandler: {
|
||||||
|
window.nsWindow.orderOut(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
func tearDown() {
|
||||||
|
hideWorkItem?.cancel()
|
||||||
|
hideWorkItem = nil
|
||||||
|
|
||||||
|
volumeWindow?.nsWindow.orderOut(nil)
|
||||||
|
brightnessWindow?.nsWindow.orderOut(nil)
|
||||||
|
backlightWindow?.nsWindow.orderOut(nil)
|
||||||
|
|
||||||
|
volumeWindow = nil
|
||||||
|
brightnessWindow = nil
|
||||||
|
backlightWindow = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OSD Window Container
|
||||||
|
|
||||||
|
private struct OSDWindow {
|
||||||
|
let nsWindow: NSWindow
|
||||||
|
let hostingView: NSHostingView<CustomOSDView>
|
||||||
|
let type: SneakContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
import Defaults
|
import Defaults
|
||||||
import EventKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
import os
|
import os
|
||||||
|
|
@ -36,59 +35,23 @@ final class ReminderLiveActivityManager: ObservableObject {
|
||||||
|
|
||||||
private let logger: os.Logger = os.Logger(subsystem: "com.ebullioscopic.Atoll", category: "ReminderLiveActivity")
|
private let logger: os.Logger = os.Logger(subsystem: "com.ebullioscopic.Atoll", category: "ReminderLiveActivity")
|
||||||
|
|
||||||
private enum RefreshReason: String {
|
|
||||||
case initialization
|
|
||||||
case defaults
|
|
||||||
case manual
|
|
||||||
case eventStoreChange
|
|
||||||
case calendarListChange
|
|
||||||
case calendarEventsUpdate
|
|
||||||
case evaluation
|
|
||||||
case hover
|
|
||||||
case ticker
|
|
||||||
case other
|
|
||||||
}
|
|
||||||
|
|
||||||
private var nextReminder: ReminderEntry?
|
private var nextReminder: ReminderEntry?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var tickerTask: Task<Void, Never>? { didSet { oldValue?.cancel() } }
|
private var tickerTask: Task<Void, Never>? { didSet { oldValue?.cancel() } }
|
||||||
private var evaluationTask: Task<Void, Never>?
|
private var evaluationTask: Task<Void, Never>?
|
||||||
private var refreshTask: Task<Void, Never>?
|
|
||||||
private var pendingRefreshTask: Task<Void, Never>?
|
|
||||||
private var pendingRefreshForce = false
|
|
||||||
private var pendingRefreshToken = UUID()
|
|
||||||
private var pendingRefreshReason: RefreshReason = .other
|
|
||||||
private var lastKnownCalendarIDs = Set<String>()
|
|
||||||
private var lastRefreshDate: Date?
|
|
||||||
private var lastRefreshCompletionDate: Date?
|
|
||||||
private let minimumRefreshInterval: TimeInterval = 60
|
|
||||||
private let refreshDebounceInterval: TimeInterval = 0.3
|
|
||||||
private var refreshTaskToken = UUID()
|
|
||||||
private var hasShownCriticalSneakPeek = false
|
private var hasShownCriticalSneakPeek = false
|
||||||
private let eventStoreChangeNoiseInterval: TimeInterval = 4
|
private var latestEvents: [EventModel] = []
|
||||||
private let eventStoreForceThrottleInterval: TimeInterval = 20
|
|
||||||
private let manualRefreshSuppressionInterval: TimeInterval = 20
|
|
||||||
private var lastEventStoreForceRefreshDate: Date?
|
|
||||||
private var lastManualRefreshDate: Date?
|
|
||||||
private var pendingEventStoreChange: (date: Date, reason: RefreshReason)?
|
|
||||||
private var reminderCompletionSuppressionDeadline: Date?
|
|
||||||
private let reminderCompletionSuppressionInterval: TimeInterval = 15
|
|
||||||
private var lastUpcomingSignature: [String] = []
|
|
||||||
private var lastNoopEventStoreRefreshDate: Date?
|
|
||||||
private let noopEventStoreSuppressionInterval: TimeInterval = 30
|
|
||||||
private var lastCalendarEventSnapshot: [EventModel] = []
|
|
||||||
private var lastCalendarSnapshotDate: Date?
|
|
||||||
private var lastCalendarEventSnapshotSignature: [String] = []
|
|
||||||
|
|
||||||
private let calendarService: CalendarServiceProviding
|
|
||||||
private let calendarManager = CalendarManager.shared
|
private let calendarManager = CalendarManager.shared
|
||||||
|
|
||||||
var isActive: Bool { activeReminder != nil }
|
var isActive: Bool { activeReminder != nil }
|
||||||
|
|
||||||
private init(calendarService: CalendarServiceProviding = CalendarService()) {
|
private init() {
|
||||||
self.calendarService = calendarService
|
latestEvents = calendarManager.events
|
||||||
setupObservers()
|
setupObservers()
|
||||||
scheduleRefresh(force: true, reason: .initialization)
|
if !latestEvents.isEmpty {
|
||||||
|
recalculateUpcomingEntries(reason: "initialization")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupObservers() {
|
private func setupObservers() {
|
||||||
|
|
@ -96,7 +59,7 @@ final class ReminderLiveActivityManager: ObservableObject {
|
||||||
.sink { [weak self] change in
|
.sink { [weak self] change in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
if change.newValue {
|
if change.newValue {
|
||||||
self.scheduleRefresh(force: true, reason: .defaults)
|
self.recalculateUpcomingEntries(reason: "defaults-toggle")
|
||||||
} else {
|
} else {
|
||||||
self.deactivateReminder()
|
self.deactivateReminder()
|
||||||
}
|
}
|
||||||
|
|
@ -106,7 +69,19 @@ final class ReminderLiveActivityManager: ObservableObject {
|
||||||
Defaults.publisher(.reminderLeadTime, options: [])
|
Defaults.publisher(.reminderLeadTime, options: [])
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.scheduleRefresh(force: true, reason: .defaults)
|
self.recalculateUpcomingEntries(reason: "lead-time")
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
Defaults.publisher(.hideAllDayEvents, options: [])
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.recalculateUpcomingEntries(reason: "hide-all-day")
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
Defaults.publisher(.hideCompletedReminders, options: [])
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.recalculateUpcomingEntries(reason: "hide-completed")
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
|
@ -120,16 +95,6 @@ final class ReminderLiveActivityManager: ObservableObject {
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
calendarManager.$allCalendars
|
|
||||||
.sink { [weak self] calendars in
|
|
||||||
guard let self else { return }
|
|
||||||
let identifiers = Set(calendars.map { $0.id })
|
|
||||||
guard identifiers != self.lastKnownCalendarIDs else { return }
|
|
||||||
self.lastKnownCalendarIDs = identifiers
|
|
||||||
self.handleEventStoreDrivenChange(reason: .calendarListChange)
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
calendarManager.$events
|
calendarManager.$events
|
||||||
.receive(on: RunLoop.main)
|
.receive(on: RunLoop.main)
|
||||||
.sink { [weak self] events in
|
.sink { [weak self] events in
|
||||||
|
|
@ -137,26 +102,13 @@ final class ReminderLiveActivityManager: ObservableObject {
|
||||||
self.handleCalendarEventsUpdate(events)
|
self.handleCalendarEventsUpdate(events)
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
NotificationCenter.default.publisher(for: .EKEventStoreChanged)
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
self.handleEventStoreDrivenChange(reason: .eventStoreChange)
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cancelAllTimers() {
|
private func cancelAllTimers() {
|
||||||
tickerTask = nil
|
tickerTask = nil
|
||||||
evaluationTask?.cancel()
|
evaluationTask?.cancel()
|
||||||
evaluationTask = nil
|
evaluationTask = nil
|
||||||
pendingRefreshTask?.cancel()
|
|
||||||
pendingRefreshTask = nil
|
|
||||||
pendingRefreshForce = false
|
|
||||||
refreshTask?.cancel()
|
|
||||||
refreshTask = nil
|
|
||||||
hasShownCriticalSneakPeek = false
|
hasShownCriticalSneakPeek = false
|
||||||
pendingEventStoreChange = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deactivateReminder() {
|
private func deactivateReminder() {
|
||||||
|
|
@ -165,32 +117,13 @@ final class ReminderLiveActivityManager: ObservableObject {
|
||||||
upcomingEntries = []
|
upcomingEntries = []
|
||||||
activeWindowReminders = []
|
activeWindowReminders = []
|
||||||
cancelAllTimers()
|
cancelAllTimers()
|
||||||
lastUpcomingSignature = []
|
|
||||||
lastNoopEventStoreRefreshDate = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func selectedCalendarIDs() -> [String] {
|
|
||||||
calendarManager.allCalendars
|
|
||||||
.filter { calendarManager.getCalendarSelected($0) }
|
|
||||||
.map { $0.id }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleCalendarEventsUpdate(_ events: [EventModel]) {
|
private func handleCalendarEventsUpdate(_ events: [EventModel]) {
|
||||||
let now = Date()
|
latestEvents = events
|
||||||
let signature = events.map { $0.id }
|
|
||||||
if signature == lastCalendarEventSnapshotSignature,
|
|
||||||
let snapshotDate = lastCalendarSnapshotDate,
|
|
||||||
now.timeIntervalSince(snapshotDate) < 5 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lastCalendarEventSnapshot = events
|
|
||||||
lastCalendarSnapshotDate = now
|
|
||||||
lastCalendarEventSnapshotSignature = signature
|
|
||||||
|
|
||||||
guard Defaults[.enableReminderLiveActivity] else { return }
|
guard Defaults[.enableReminderLiveActivity] else { return }
|
||||||
logger.debug("[Reminder] Applying calendar snapshot update (events=\(events.count, privacy: .public))")
|
logger.debug("[Reminder] Applying calendar snapshot update (events=\(events.count, privacy: .public))")
|
||||||
refreshFromEvents(events, referenceDate: now, reason: .calendarEventsUpdate)
|
recalculateUpcomingEntries(reason: "calendar-events")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldHide(_ event: EventModel) -> Bool {
|
private func shouldHide(_ event: EventModel) -> Bool {
|
||||||
|
|
@ -211,6 +144,45 @@ final class ReminderLiveActivityManager: ObservableObject {
|
||||||
return ReminderEntry(event: event, triggerDate: trigger, leadTime: TimeInterval(leadSeconds))
|
return ReminderEntry(event: event, triggerDate: trigger, leadTime: TimeInterval(leadSeconds))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func recalculateUpcomingEntries(referenceDate: Date = Date(), reason: String) {
|
||||||
|
guard Defaults[.enableReminderLiveActivity] else {
|
||||||
|
deactivateReminder()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard calendarManager.hasReminderAccess else {
|
||||||
|
deactivateReminder()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let leadMinutes = Defaults[.reminderLeadTime]
|
||||||
|
let upcoming = latestEvents
|
||||||
|
.filter { !shouldHide($0) }
|
||||||
|
.compactMap { makeEntry(from: $0, leadMinutes: leadMinutes, referenceDate: referenceDate) }
|
||||||
|
.sorted { $0.triggerDate < $1.triggerDate }
|
||||||
|
|
||||||
|
upcomingEntries = upcoming
|
||||||
|
updateActiveWindowReminders(for: referenceDate)
|
||||||
|
|
||||||
|
guard let first = upcoming.first else {
|
||||||
|
clearActiveReminderState()
|
||||||
|
logger.debug("[Reminder] No upcoming reminders found (reason=\(reason, privacy: .public))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("[Reminder] Next reminder ‘\(first.event.title, privacy: .public)’ (reason=\(reason, privacy: .public))")
|
||||||
|
handleEntrySelection(first, referenceDate: referenceDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearActiveReminderState() {
|
||||||
|
nextReminder = nil
|
||||||
|
if activeReminder != nil {
|
||||||
|
activeReminder = nil
|
||||||
|
}
|
||||||
|
activeWindowReminders = []
|
||||||
|
cancelAllTimers()
|
||||||
|
}
|
||||||
|
|
||||||
private func scheduleEvaluation(at date: Date) {
|
private func scheduleEvaluation(at date: Date) {
|
||||||
evaluationTask?.cancel()
|
evaluationTask?.cancel()
|
||||||
let delay = date.timeIntervalSinceNow
|
let delay = date.timeIntervalSinceNow
|
||||||
|
|
@ -226,88 +198,6 @@ final class ReminderLiveActivityManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleRefresh(force: Bool, reason: RefreshReason = .other) {
|
|
||||||
let wasPending = pendingRefreshTask != nil
|
|
||||||
pendingRefreshForce = pendingRefreshForce || force
|
|
||||||
pendingRefreshToken = UUID()
|
|
||||||
pendingRefreshReason = reason
|
|
||||||
let token = pendingRefreshToken
|
|
||||||
|
|
||||||
logger.debug("[Reminder] scheduleRefresh reason=\(reason.rawValue, privacy: .public) force=\(force, privacy: .public) pending=\(wasPending, privacy: .public)")
|
|
||||||
|
|
||||||
refreshTask?.cancel()
|
|
||||||
pendingRefreshTask?.cancel()
|
|
||||||
pendingRefreshTask = Task { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
do {
|
|
||||||
try await Task.sleep(nanoseconds: UInt64(self.refreshDebounceInterval * 1_000_000_000))
|
|
||||||
} catch {
|
|
||||||
self.logger.debug("[Reminder] Debounced refresh for reason=\(reason.rawValue, privacy: .public) was cancelled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await self.executeScheduledRefresh(token: token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func executeScheduledRefresh(token: UUID) async {
|
|
||||||
guard pendingRefreshToken == token else { return }
|
|
||||||
pendingRefreshTask = nil
|
|
||||||
|
|
||||||
if Task.isCancelled {
|
|
||||||
logger.debug("[Reminder] Cancelled refresh token=\(token.uuidString, privacy: .public)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let reason = pendingRefreshReason
|
|
||||||
let now = Date()
|
|
||||||
let force = pendingRefreshForce
|
|
||||||
if !force, let last = lastRefreshDate {
|
|
||||||
let elapsed = now.timeIntervalSince(last)
|
|
||||||
if elapsed < minimumRefreshInterval {
|
|
||||||
let remaining = max(minimumRefreshInterval - elapsed, refreshDebounceInterval)
|
|
||||||
logger.debug("[Reminder] Deferring refresh reason=\(reason.rawValue, privacy: .public); next attempt in \(remaining, format: .fixed(precision: 2))s")
|
|
||||||
pendingRefreshToken = UUID()
|
|
||||||
let nextToken = pendingRefreshToken
|
|
||||||
pendingRefreshTask = Task { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
do {
|
|
||||||
try await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
|
||||||
} catch {
|
|
||||||
self.logger.debug("[Reminder] Deferred refresh for reason=\(reason.rawValue, privacy: .public) was cancelled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await self.executeScheduledRefresh(token: nextToken)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingRefreshForce = false
|
|
||||||
lastRefreshDate = now
|
|
||||||
|
|
||||||
let startTime = Date()
|
|
||||||
logger.debug("[Reminder] Starting refresh reason=\(reason.rawValue, privacy: .public) force=\(force, privacy: .public)")
|
|
||||||
|
|
||||||
refreshTask?.cancel()
|
|
||||||
let taskToken = UUID()
|
|
||||||
refreshTaskToken = taskToken
|
|
||||||
let task = Task { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
await self.refreshUpcomingReminder(force: force, reason: reason)
|
|
||||||
}
|
|
||||||
refreshTask = task
|
|
||||||
defer {
|
|
||||||
if refreshTaskToken == taskToken {
|
|
||||||
refreshTask = nil
|
|
||||||
lastRefreshCompletionDate = Date()
|
|
||||||
let duration = Date().timeIntervalSince(startTime)
|
|
||||||
logger.debug("[Reminder] Finished refresh reason=\(reason.rawValue, privacy: .public) duration=\(duration, format: .fixed(precision: 2))s upcoming=\(self.upcomingEntries.count, privacy: .public)")
|
|
||||||
processPendingEventStoreChangeIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = try? await task.value
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startTickerIfNeeded() {
|
private func startTickerIfNeeded() {
|
||||||
guard tickerTask == nil else { return }
|
guard tickerTask == nil else { return }
|
||||||
tickerTask = Task { [weak self] in
|
tickerTask = Task { [weak self] in
|
||||||
|
|
@ -328,109 +218,16 @@ final class ReminderLiveActivityManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleEntrySelection(_ entry: ReminderEntry?, referenceDate: Date) {
|
private func handleEntrySelection(_ entry: ReminderEntry?, referenceDate: Date) {
|
||||||
|
guard nextReminder != entry else {
|
||||||
|
Task { await self.evaluateCurrentState(at: referenceDate) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
nextReminder = entry
|
nextReminder = entry
|
||||||
hasShownCriticalSneakPeek = false
|
hasShownCriticalSneakPeek = false
|
||||||
Task { await self.evaluateCurrentState(at: referenceDate) }
|
Task { await self.evaluateCurrentState(at: referenceDate) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshFromEvents(_ events: [EventModel], referenceDate: Date, reason: RefreshReason) {
|
|
||||||
let leadMinutes = Defaults[.reminderLeadTime]
|
|
||||||
let upcoming = events
|
|
||||||
.filter { !shouldHide($0) }
|
|
||||||
.compactMap { makeEntry(from: $0, leadMinutes: leadMinutes, referenceDate: referenceDate) }
|
|
||||||
.sorted { $0.triggerDate < $1.triggerDate }
|
|
||||||
|
|
||||||
upcomingEntries = upcoming
|
|
||||||
updateActiveWindowReminders(for: referenceDate)
|
|
||||||
|
|
||||||
logger.debug("[Reminder] Reduced \(events.count, privacy: .public) events to \(upcoming.count, privacy: .public) upcoming reminders")
|
|
||||||
|
|
||||||
guard let first = upcoming.first else {
|
|
||||||
deactivateReminder()
|
|
||||||
logger.debug("[Reminder] No upcoming reminders found; awaiting next calendar update")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("[Reminder] Next reminder ‘\(first.event.title, privacy: .public)’ at \(first.triggerDate.timeIntervalSinceReferenceDate, privacy: .public)")
|
|
||||||
|
|
||||||
handleEntrySelection(first, referenceDate: referenceDate)
|
|
||||||
|
|
||||||
let signature = upcoming.map { $0.event.id }
|
|
||||||
if lastUpcomingSignature == signature {
|
|
||||||
if reason == .eventStoreChange {
|
|
||||||
lastNoopEventStoreRefreshDate = Date()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lastNoopEventStoreRefreshDate = nil
|
|
||||||
}
|
|
||||||
lastUpcomingSignature = signature
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestRefresh(force: Bool = false) {
|
|
||||||
let reason: RefreshReason = force ? .manual : .other
|
|
||||||
if force {
|
|
||||||
let now = Date()
|
|
||||||
if let lastManualRefreshDate,
|
|
||||||
now.timeIntervalSince(lastManualRefreshDate) < manualRefreshSuppressionInterval {
|
|
||||||
let delta = now.timeIntervalSince(lastManualRefreshDate)
|
|
||||||
logger.debug("[Reminder] Skipping manual refresh; last manual was \(delta, format: .fixed(precision: 2))s ago")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lastManualRefreshDate = now
|
|
||||||
}
|
|
||||||
scheduleRefresh(force: force, reason: reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshUpcomingReminder(force: Bool = false, reason: RefreshReason = .other) async {
|
|
||||||
guard Defaults[.enableReminderLiveActivity] else {
|
|
||||||
deactivateReminder()
|
|
||||||
logger.debug("[Reminder] Refresh aborted (feature disabled)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard calendarManager.hasReminderAccess else {
|
|
||||||
deactivateReminder()
|
|
||||||
logger.debug("[Reminder] Refresh aborted; reminder permissions missing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
if !force, let entry = nextReminder, entry.event.start > now {
|
|
||||||
logger.debug("[Reminder] Skipping fetch; cached entry \(entry.event.title, privacy: .public) still valid for reason=\(reason.rawValue, privacy: .public)")
|
|
||||||
await evaluateCurrentState(at: now)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if let snapshotDate = lastCalendarSnapshotDate,
|
|
||||||
now.timeIntervalSince(snapshotDate) < 5,
|
|
||||||
!lastCalendarEventSnapshot.isEmpty {
|
|
||||||
logger.debug("[Reminder] Using calendar snapshot (age=\(now.timeIntervalSince(snapshotDate), format: .fixed(precision: 2))s) for reason=\(reason.rawValue, privacy: .public)")
|
|
||||||
refreshFromEvents(lastCalendarEventSnapshot, referenceDate: now, reason: reason)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let calendars = selectedCalendarIDs()
|
|
||||||
guard !calendars.isEmpty else {
|
|
||||||
deactivateReminder()
|
|
||||||
logger.debug("[Reminder] Refresh aborted; no calendars selected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let windowEnd = Calendar.current.date(byAdding: .hour, value: 24, to: now) ?? now.addingTimeInterval(24 * 60 * 60)
|
|
||||||
logger.debug("[Reminder] Fetching reminders (reason=\(reason.rawValue, privacy: .public), force=\(force, privacy: .public), calendars=\(calendars.count, privacy: .public))")
|
|
||||||
let fetchStart = Date()
|
|
||||||
let events = await calendarService.events(from: now, to: windowEnd, calendars: calendars)
|
|
||||||
let fetchDuration = Date().timeIntervalSince(fetchStart)
|
|
||||||
logger.debug("[Reminder] EventKit fetch completed (events=\(events.count, privacy: .public)) in \(fetchDuration, format: .fixed(precision: 2))s")
|
|
||||||
lastCalendarEventSnapshot = events
|
|
||||||
lastCalendarSnapshotDate = now
|
|
||||||
lastCalendarEventSnapshotSignature = events.map { $0.id }
|
|
||||||
await MainActor.run {
|
|
||||||
self.refreshFromEvents(events, referenceDate: now, reason: reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func evaluateCurrentState(at date: Date) async {
|
func evaluateCurrentState(at date: Date) async {
|
||||||
guard Defaults[.enableReminderLiveActivity] else {
|
guard Defaults[.enableReminderLiveActivity] else {
|
||||||
deactivateReminder()
|
deactivateReminder()
|
||||||
|
|
@ -450,13 +247,9 @@ final class ReminderLiveActivityManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.event.start <= date {
|
if entry.event.start <= date {
|
||||||
activeReminder = nil
|
clearActiveReminderState()
|
||||||
nextReminder = nil
|
logger.debug("[Reminder] Reminder reached start time; reevaluating reminders from cache")
|
||||||
stopTicker()
|
recalculateUpcomingEntries(referenceDate: date, reason: "evaluation-complete")
|
||||||
hasShownCriticalSneakPeek = false
|
|
||||||
logger.debug("[Reminder] Reminder reached start time; requesting evaluation refresh")
|
|
||||||
reminderCompletionSuppressionDeadline = Date().addingTimeInterval(reminderCompletionSuppressionInterval)
|
|
||||||
scheduleRefresh(force: true, reason: .evaluation)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -528,83 +321,6 @@ final class ReminderLiveActivityManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleEventStoreDrivenChange(reason: RefreshReason) {
|
|
||||||
processEventStoreChange(at: Date(), reason: reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func processEventStoreChange(at date: Date, reason: RefreshReason) {
|
|
||||||
if let manual = lastManualRefreshDate {
|
|
||||||
let delta = date.timeIntervalSince(manual)
|
|
||||||
if delta < manualRefreshSuppressionInterval {
|
|
||||||
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public) change; manual refresh cooldown \(delta, format: .fixed(precision: 2))s")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let lastRefreshStart = lastRefreshDate {
|
|
||||||
let sinceStart = date.timeIntervalSince(lastRefreshStart)
|
|
||||||
if sinceStart >= 0 && sinceStart < eventStoreChangeNoiseInterval {
|
|
||||||
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); \(sinceStart, format: .fixed(precision: 2))s since start")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let lastCompletion = lastRefreshCompletionDate {
|
|
||||||
let sinceCompletion = abs(date.timeIntervalSince(lastCompletion))
|
|
||||||
if sinceCompletion < eventStoreChangeNoiseInterval {
|
|
||||||
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); \(sinceCompletion, format: .fixed(precision: 2))s since completion")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if reason == .eventStoreChange,
|
|
||||||
let suppressionDeadline = reminderCompletionSuppressionDeadline,
|
|
||||||
date <= suppressionDeadline {
|
|
||||||
let remaining = suppressionDeadline.timeIntervalSince(date)
|
|
||||||
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); reminder completion suppression \(remaining, format: .fixed(precision: 2))s")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if reason == .eventStoreChange,
|
|
||||||
let noopDate = lastNoopEventStoreRefreshDate,
|
|
||||||
date.timeIntervalSince(noopDate) < noopEventStoreSuppressionInterval {
|
|
||||||
let remaining = noopEventStoreSuppressionInterval - date.timeIntervalSince(noopDate)
|
|
||||||
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); identical upcoming entries \(remaining, format: .fixed(precision: 2))s window")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if let lastForce = lastEventStoreForceRefreshDate {
|
|
||||||
let sinceForce = date.timeIntervalSince(lastForce)
|
|
||||||
if sinceForce < self.eventStoreForceThrottleInterval {
|
|
||||||
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); throttle window remaining \(self.eventStoreForceThrottleInterval - sinceForce, format: .fixed(precision: 2))s")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if refreshTask != nil {
|
|
||||||
pendingEventStoreChange = (date, reason)
|
|
||||||
logger.debug("[Reminder] Queued \(reason.rawValue, privacy: .public) change while refresh is running")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
triggerEventStoreForcedRefresh(at: date, reason: reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func processPendingEventStoreChangeIfNeeded() {
|
|
||||||
guard let pendingChange = pendingEventStoreChange else { return }
|
|
||||||
pendingEventStoreChange = nil
|
|
||||||
processEventStoreChange(at: pendingChange.date, reason: pendingChange.reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func triggerEventStoreForcedRefresh(at date: Date = Date(), reason: RefreshReason) {
|
|
||||||
lastEventStoreForceRefreshDate = date
|
|
||||||
if reason == .eventStoreChange, reminderCompletionSuppressionDeadline != nil {
|
|
||||||
reminderCompletionSuppressionDeadline = nil
|
|
||||||
}
|
|
||||||
logger.debug("[Reminder] Triggering event-store refresh for \(reason.rawValue, privacy: .public)")
|
|
||||||
scheduleRefresh(force: true, reason: reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func additionalHeight(forRowCount rowCount: Int) -> CGFloat {
|
static func additionalHeight(forRowCount rowCount: Int) -> CGFloat {
|
||||||
guard rowCount > 0 else { return 0 }
|
guard rowCount > 0 else { return 0 }
|
||||||
let rows = CGFloat(rowCount)
|
let rows = CGFloat(rowCount)
|
||||||
|
|
|
||||||
|
|
@ -145,30 +145,61 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
||||||
if HUDSuppressionCoordinator.shared.shouldSuppressVolumeHUD {
|
if HUDSuppressionCoordinator.shared.shouldSuppressVolumeHUD {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
coordinator?.toggleSneakPeek(
|
|
||||||
status: true,
|
// Send to custom OSD if enabled
|
||||||
type: .volume,
|
if Defaults[.enableCustomOSD] && Defaults[.enableOSDVolume] {
|
||||||
value: CGFloat(value),
|
Task { @MainActor in
|
||||||
icon: ""
|
CustomOSDWindowManager.shared.showVolume(value: CGFloat(value))
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to notch HUD if enabled and OSD is not enabled
|
||||||
|
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableVolumeHUD] {
|
||||||
|
coordinator?.toggleSneakPeek(
|
||||||
|
status: true,
|
||||||
|
type: .volume,
|
||||||
|
value: CGFloat(value),
|
||||||
|
icon: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendBrightnessNotification(value: Float) {
|
private func sendBrightnessNotification(value: Float) {
|
||||||
coordinator?.toggleSneakPeek(
|
// Send to custom OSD if enabled
|
||||||
status: true,
|
if Defaults[.enableCustomOSD] && Defaults[.enableOSDBrightness] {
|
||||||
type: .brightness,
|
Task { @MainActor in
|
||||||
value: CGFloat(value),
|
CustomOSDWindowManager.shared.showBrightness(value: CGFloat(value))
|
||||||
icon: ""
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
|
// Send to notch HUD if enabled and OSD is not enabled
|
||||||
|
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableBrightnessHUD] {
|
||||||
|
coordinator?.toggleSneakPeek(
|
||||||
|
status: true,
|
||||||
|
type: .brightness,
|
||||||
|
value: CGFloat(value),
|
||||||
|
icon: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendKeyboardBacklightNotification(value: Float) {
|
private func sendKeyboardBacklightNotification(value: Float) {
|
||||||
coordinator?.toggleSneakPeek(
|
// Send to custom OSD if enabled
|
||||||
status: true,
|
if Defaults[.enableCustomOSD] && Defaults[.enableOSDKeyboardBacklight] {
|
||||||
type: .backlight,
|
Task { @MainActor in
|
||||||
value: CGFloat(value),
|
CustomOSDWindowManager.shared.showBacklight(value: CGFloat(value))
|
||||||
icon: ""
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
|
// Send to notch HUD if enabled and OSD is not enabled
|
||||||
|
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableKeyboardBacklightHUD] {
|
||||||
|
coordinator?.toggleSneakPeek(
|
||||||
|
status: true,
|
||||||
|
type: .backlight,
|
||||||
|
value: CGFloat(value),
|
||||||
|
icon: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureKeyboardBacklightCallback() {
|
private func configureKeyboardBacklightCallback() {
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,40 @@ class SystemHUDManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupSettingsObserver() {
|
private func setupSettingsObserver() {
|
||||||
// Observe master toggle
|
// Observe master HUD toggle
|
||||||
Defaults.publisher(.enableSystemHUD, options: []).sink { [weak self] change in
|
Defaults.publisher(.enableSystemHUD, options: []).sink { [weak self] change in
|
||||||
|
guard let self = self, self.isSetupComplete else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
if change.newValue && !Defaults[.enableCustomOSD] {
|
||||||
|
await self.startSystemObserver()
|
||||||
|
} else if !Defaults[.enableCustomOSD] {
|
||||||
|
// Only stop if OSD is also disabled
|
||||||
|
await self.stopSystemObserver()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Observe OSD toggle
|
||||||
|
Defaults.publisher(.enableCustomOSD, options: []).sink { [weak self] change in
|
||||||
guard let self = self, self.isSetupComplete else {
|
guard let self = self, self.isSetupComplete else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
if change.newValue {
|
if change.newValue {
|
||||||
await self.startSystemHUD()
|
// Start observer for OSD if not already running
|
||||||
|
if self.changesObserver == nil {
|
||||||
|
await self.startSystemObserver()
|
||||||
|
}
|
||||||
|
} else if Defaults[.enableSystemHUD] {
|
||||||
|
// Keep observer running for HUD
|
||||||
|
if self.changesObserver == nil {
|
||||||
|
await self.startSystemObserver()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await self.stopSystemHUD()
|
// Stop observer if both HUD and OSD are disabled
|
||||||
|
await self.stopSystemObserver()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
@ -70,6 +94,40 @@ class SystemHUDManager {
|
||||||
keyboardBacklightEnabled: change.newValue
|
keyboardBacklightEnabled: change.newValue
|
||||||
)
|
)
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Observe individual OSD toggles
|
||||||
|
Defaults.publisher(.enableOSDVolume, options: []).sink { [weak self] change in
|
||||||
|
guard let self = self, self.isSetupComplete, Defaults[.enableCustomOSD] else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.changesObserver?.update(
|
||||||
|
volumeEnabled: change.newValue,
|
||||||
|
brightnessEnabled: Defaults[.enableOSDBrightness],
|
||||||
|
keyboardBacklightEnabled: Defaults[.enableOSDKeyboardBacklight]
|
||||||
|
)
|
||||||
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
Defaults.publisher(.enableOSDBrightness, options: []).sink { [weak self] change in
|
||||||
|
guard let self = self, self.isSetupComplete, Defaults[.enableCustomOSD] else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.changesObserver?.update(
|
||||||
|
volumeEnabled: Defaults[.enableOSDVolume],
|
||||||
|
brightnessEnabled: change.newValue,
|
||||||
|
keyboardBacklightEnabled: Defaults[.enableOSDKeyboardBacklight]
|
||||||
|
)
|
||||||
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
Defaults.publisher(.enableOSDKeyboardBacklight, options: []).sink { [weak self] change in
|
||||||
|
guard let self = self, self.isSetupComplete, Defaults[.enableCustomOSD] else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.changesObserver?.update(
|
||||||
|
volumeEnabled: Defaults[.enableOSDVolume],
|
||||||
|
brightnessEnabled: Defaults[.enableOSDBrightness],
|
||||||
|
keyboardBacklightEnabled: change.newValue
|
||||||
|
)
|
||||||
|
}.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
@ -82,9 +140,15 @@ class SystemHUDManager {
|
||||||
func setup(coordinator: DynamicIslandViewCoordinator) {
|
func setup(coordinator: DynamicIslandViewCoordinator) {
|
||||||
self.coordinator = coordinator
|
self.coordinator = coordinator
|
||||||
|
|
||||||
if Defaults[.enableSystemHUD] {
|
// Initialize OSD manager
|
||||||
|
Task { @MainActor in
|
||||||
|
CustomOSDWindowManager.shared.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start observer if either HUD or OSD is enabled
|
||||||
|
if Defaults[.enableSystemHUD] || Defaults[.enableCustomOSD] {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
await startSystemHUD()
|
await startSystemObserver()
|
||||||
self.isSetupComplete = true
|
self.isSetupComplete = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -93,42 +157,55 @@ class SystemHUDManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func startSystemHUD() async {
|
private func startSystemObserver() async {
|
||||||
guard let coordinator = coordinator, !isSystemOperationInProgress else { return }
|
guard let coordinator = coordinator, !isSystemOperationInProgress else { return }
|
||||||
|
|
||||||
isSystemOperationInProgress = true
|
isSystemOperationInProgress = true
|
||||||
await stopSystemHUD() // Stop any existing observer
|
await stopSystemObserver() // Stop any existing observer
|
||||||
|
|
||||||
changesObserver = SystemChangesObserver(coordinator: coordinator)
|
changesObserver = SystemChangesObserver(coordinator: coordinator)
|
||||||
let volumeEnabled = Defaults[.enableVolumeHUD]
|
|
||||||
let brightnessEnabled = Defaults[.enableBrightnessHUD]
|
// Determine which controls to enable based on HUD or OSD mode
|
||||||
let keyboardBacklightEnabled = Defaults[.enableKeyboardBacklightHUD]
|
let volumeEnabled: Bool
|
||||||
|
let brightnessEnabled: Bool
|
||||||
|
let keyboardBacklightEnabled: Bool
|
||||||
|
|
||||||
|
if Defaults[.enableCustomOSD] {
|
||||||
|
volumeEnabled = Defaults[.enableOSDVolume]
|
||||||
|
brightnessEnabled = Defaults[.enableOSDBrightness]
|
||||||
|
keyboardBacklightEnabled = Defaults[.enableOSDKeyboardBacklight]
|
||||||
|
} else {
|
||||||
|
volumeEnabled = Defaults[.enableVolumeHUD]
|
||||||
|
brightnessEnabled = Defaults[.enableBrightnessHUD]
|
||||||
|
keyboardBacklightEnabled = Defaults[.enableKeyboardBacklightHUD]
|
||||||
|
}
|
||||||
|
|
||||||
changesObserver?.startObserving(
|
changesObserver?.startObserving(
|
||||||
volumeEnabled: volumeEnabled,
|
volumeEnabled: volumeEnabled,
|
||||||
brightnessEnabled: brightnessEnabled,
|
brightnessEnabled: brightnessEnabled,
|
||||||
keyboardBacklightEnabled: keyboardBacklightEnabled
|
keyboardBacklightEnabled: keyboardBacklightEnabled
|
||||||
)
|
)
|
||||||
|
|
||||||
print("System HUD replacement started")
|
print("System observer started (HUD: \(Defaults[.enableSystemHUD]), OSD: \(Defaults[.enableCustomOSD]))")
|
||||||
isSystemOperationInProgress = false
|
isSystemOperationInProgress = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func stopSystemHUD() async {
|
private func stopSystemObserver() async {
|
||||||
guard !isSystemOperationInProgress else { return }
|
guard !isSystemOperationInProgress else { return }
|
||||||
|
|
||||||
isSystemOperationInProgress = true
|
isSystemOperationInProgress = true
|
||||||
changesObserver?.stopObserving()
|
changesObserver?.stopObserving()
|
||||||
changesObserver = nil
|
changesObserver = nil
|
||||||
|
|
||||||
print("System HUD replacement stopped")
|
print("System observer stopped")
|
||||||
isSystemOperationInProgress = false
|
isSystemOperationInProgress = false
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
cancellables.removeAll()
|
cancellables.removeAll()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
await stopSystemHUD()
|
await stopSystemObserver()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -503,6 +503,8 @@ extension Defaults.Keys {
|
||||||
|
|
||||||
// MARK: Calendar
|
// MARK: Calendar
|
||||||
static let calendarSelectionState = Key<CalendarSelectionState>("calendarSelectionState", default: .all)
|
static let calendarSelectionState = Key<CalendarSelectionState>("calendarSelectionState", default: .all)
|
||||||
|
static let showFullEventTitles = Key<Bool>("showFullEventTitles", default: false)
|
||||||
|
static let autoScrollToNextEvent = Key<Bool>("autoScrollToNextEvent", default: true)
|
||||||
|
|
||||||
// MARK: Fullscreen Media Detection
|
// MARK: Fullscreen Media Detection
|
||||||
static let alwaysHideInFullscreen = Key<Bool>("alwaysHideInFullscreen", default: false)
|
static let alwaysHideInFullscreen = Key<Bool>("alwaysHideInFullscreen", default: false)
|
||||||
|
|
@ -587,6 +589,15 @@ extension Defaults.Keys {
|
||||||
static let enableKeyboardBacklightHUD = Key<Bool>("enableKeyboardBacklightHUD", default: true)
|
static let enableKeyboardBacklightHUD = Key<Bool>("enableKeyboardBacklightHUD", default: true)
|
||||||
static let systemHUDSensitivity = Key<Int>("systemHUDSensitivity", default: 5)
|
static let systemHUDSensitivity = Key<Int>("systemHUDSensitivity", default: 5)
|
||||||
|
|
||||||
|
// MARK: Custom OSD Window Feature
|
||||||
|
static let enableCustomOSD = Key<Bool>("enableCustomOSD", default: false)
|
||||||
|
static let hasSeenOSDAlphaWarning = Key<Bool>("hasSeenOSDAlphaWarning", default: false)
|
||||||
|
static let enableOSDVolume = Key<Bool>("enableOSDVolume", default: true)
|
||||||
|
static let enableOSDBrightness = Key<Bool>("enableOSDBrightness", default: true)
|
||||||
|
static let enableOSDKeyboardBacklight = Key<Bool>("enableOSDKeyboardBacklight", default: true)
|
||||||
|
static let osdMaterial = Key<OSDMaterial>("osdMaterial", default: .frosted)
|
||||||
|
static let osdIconColorStyle = Key<OSDIconColorStyle>("osdIconColorStyle", default: .white)
|
||||||
|
|
||||||
// MARK: Screen Recording Detection Feature
|
// MARK: Screen Recording Detection Feature
|
||||||
static let enableScreenRecordingDetection = Key<Bool>("enableScreenRecordingDetection", default: true)
|
static let enableScreenRecordingDetection = Key<Bool>("enableScreenRecordingDetection", default: true)
|
||||||
static let showRecordingIndicator = Key<Bool>("showRecordingIndicator", default: true)
|
static let showRecordingIndicator = Key<Bool>("showRecordingIndicator", default: true)
|
||||||
|
|
|
||||||
|
|
@ -234,13 +234,11 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
DispatchQueue.main.async(execute: applyWindowResize)
|
DispatchQueue.main.async(execute: applyWindowResize)
|
||||||
}
|
}
|
||||||
|
|
||||||
withAnimation(.bouncy) {
|
notchSize = targetSize
|
||||||
self.notchSize = targetSize
|
notchState = .open
|
||||||
self.notchState = .open
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force music information update when notch is opened
|
// Force music information update when notch is opened
|
||||||
MusicManager.shared.forceUpdate()
|
MusicManager.shared.forceUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func calculateDynamicNotchSize() -> CGSize {
|
private func calculateDynamicNotchSize() -> CGSize {
|
||||||
|
|
@ -254,12 +252,10 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func close() {
|
func close() {
|
||||||
withAnimation(.smooth) { [weak self] in
|
let targetSize = getClosedNotchSize(screen: screen)
|
||||||
guard let self = self else { return }
|
notchSize = targetSize
|
||||||
self.notchSize = getClosedNotchSize(screen: self.screen)
|
closedNotchSize = targetSize
|
||||||
self.closedNotchSize = self.notchSize
|
notchState = .closed
|
||||||
self.notchState = .closed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the current view to shelf if it contains files and the user enables openShelfByDefault
|
// Set the current view to shelf if it contains files and the user enables openShelfByDefault
|
||||||
// Otherwise, if the user has not enabled openLastShelfByDefault, set the view to home
|
// Otherwise, if the user has not enabled openLastShelfByDefault, set the view to home
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue