mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #97 from Ebullioscopic/ui/minimalistic
Support for Reminder in Minimalistic UI
This commit is contained in:
commit
710e478e1d
7 changed files with 471 additions and 81 deletions
|
|
@ -39,7 +39,13 @@ struct ContentView: View {
|
||||||
|
|
||||||
// Dynamic sizing based on view type and graph count with smooth transitions
|
// Dynamic sizing based on view type and graph count with smooth transitions
|
||||||
var dynamicNotchSize: CGSize {
|
var dynamicNotchSize: CGSize {
|
||||||
let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
||||||
|
|
||||||
|
if Defaults[.enableMinimalisticUI] && vm.notchState == .open {
|
||||||
|
let reminderCount = reminderManager.activeWindowReminders.count
|
||||||
|
let extraHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: reminderCount)
|
||||||
|
baseSize.height += extraHeight
|
||||||
|
}
|
||||||
|
|
||||||
guard coordinator.currentView == .stats else {
|
guard coordinator.currentView == .stats else {
|
||||||
return baseSize
|
return baseSize
|
||||||
|
|
@ -81,6 +87,12 @@ struct ContentView: View {
|
||||||
@Default(.showNotHumanFace) var showNotHumanFace
|
@Default(.showNotHumanFace) var showNotHumanFace
|
||||||
@Default(.useModernCloseAnimation) var useModernCloseAnimation
|
@Default(.useModernCloseAnimation) var useModernCloseAnimation
|
||||||
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
||||||
|
private var dynamicNotchResizeAnimation: Animation? {
|
||||||
|
if enableMinimalisticUI && reminderManager.activeWindowReminders.isEmpty == false {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return .easeInOut(duration: 0.4)
|
||||||
|
}
|
||||||
|
|
||||||
private let extendedHoverPadding: CGFloat = 30
|
private let extendedHoverPadding: CGFloat = 30
|
||||||
private let zeroHeightHoverPadding: CGFloat = 10
|
private let zeroHeightHoverPadding: CGFloat = 10
|
||||||
|
|
@ -203,14 +215,14 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
|
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive {
|
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive && !vm.isReminderPopoverActive {
|
||||||
vm.close()
|
vm.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in
|
.onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isMediaOutputPopoverActive {
|
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isMediaOutputPopoverActive && !vm.isReminderPopoverActive {
|
||||||
vm.close()
|
vm.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +230,7 @@ struct ContentView: View {
|
||||||
.onChange(of: vm.shouldRecheckHover) { _, _ in
|
.onChange(of: vm.shouldRecheckHover) { _, _ in
|
||||||
// Recheck hover state when popovers are closed
|
// Recheck hover state when popovers are closed
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive && !isHovering {
|
if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive && !vm.isReminderPopoverActive && !isHovering {
|
||||||
vm.close()
|
vm.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -338,8 +350,8 @@ struct ContentView: View {
|
||||||
// .keyboardShortcut("E", modifiers: .command)
|
// .keyboardShortcut("E", modifiers: .command)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: dynamicNotchSize.width, maxHeight: dynamicNotchSize.height, alignment: .top)
|
.frame(maxWidth: dynamicNotchSize.width, maxHeight: dynamicNotchSize.height, alignment: .top)
|
||||||
.animation(.easeInOut(duration: 0.4), value: dynamicNotchSize)
|
.animation(dynamicNotchResizeAnimation, value: dynamicNotchSize)
|
||||||
.animation(.easeInOut(duration: 0.4), value: coordinator.currentView)
|
.animation(.easeInOut(duration: 0.4), value: coordinator.currentView)
|
||||||
.environmentObject(privacyManager)
|
.environmentObject(privacyManager)
|
||||||
.onChange(of: dynamicNotchSize) { oldSize, newSize in
|
.onChange(of: dynamicNotchSize) { oldSize, newSize in
|
||||||
|
|
@ -351,9 +363,11 @@ struct ContentView: View {
|
||||||
isViewTransitioning = true
|
isViewTransitioning = true
|
||||||
let workItem = DispatchWorkItem {
|
let workItem = DispatchWorkItem {
|
||||||
isViewTransitioning = false
|
isViewTransitioning = false
|
||||||
|
vm.shouldRecheckHover.toggle()
|
||||||
}
|
}
|
||||||
sizeChangeWorkItem = workItem
|
sizeChangeWorkItem = workItem
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem) // Frame animation + buffer
|
let delay: TimeInterval = dynamicNotchResizeAnimation == nil ? 0.25 : 1.0
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.shadow(color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) ? .black.opacity(0.6) : .clear, radius: Defaults[.cornerRadiusScaling] ? 10 : 5)
|
.shadow(color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) ? .black.opacity(0.6) : .clear, radius: Defaults[.cornerRadiusScaling] ? 10 : 5)
|
||||||
|
|
@ -730,11 +744,15 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if vm.notchState == .open && isViewTransitioning {
|
||||||
|
return
|
||||||
|
}
|
||||||
hoverTask = Task {
|
hoverTask = Task {
|
||||||
try? await Task.sleep(for: .milliseconds(100))
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
guard !self.isViewTransitioning else { return }
|
||||||
withAnimation(.bouncy.speed(1.2)) {
|
withAnimation(.bouncy.speed(1.2)) {
|
||||||
self.isHovering = false
|
self.isHovering = false
|
||||||
}
|
}
|
||||||
|
|
@ -794,7 +812,7 @@ struct ContentView: View {
|
||||||
let stillNotHovering = !isHovering
|
let stillNotHovering = !isHovering
|
||||||
let stillOpen = vm.notchState == .open
|
let stillOpen = vm.notchState == .open
|
||||||
let stillNoPopovers = !hasAnyActivePopovers()
|
let stillNoPopovers = !hasAnyActivePopovers()
|
||||||
let notTransitioning = !isStatsTransitioning && !isSwitchingToStats
|
let notTransitioning = !isStatsTransitioning && !isSwitchingToStats && !isViewTransitioning
|
||||||
|
|
||||||
if stillNotHovering && stillOpen && stillNoPopovers && notTransitioning {
|
if stillNotHovering && stillOpen && stillNoPopovers && notTransitioning {
|
||||||
vm.close()
|
vm.close()
|
||||||
|
|
@ -813,7 +831,8 @@ struct ContentView: View {
|
||||||
vm.isColorPickerPopoverActive ||
|
vm.isColorPickerPopoverActive ||
|
||||||
vm.isStatsPopoverActive ||
|
vm.isStatsPopoverActive ||
|
||||||
vm.isTimerPopoverActive ||
|
vm.isTimerPopoverActive ||
|
||||||
vm.isMediaOutputPopoverActive
|
vm.isMediaOutputPopoverActive ||
|
||||||
|
vm.isReminderPopoverActive
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to prevent rapid haptic feedback
|
// Helper to prevent rapid haptic feedback
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,12 @@ struct DynamicNotchApp: App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension AppDelegate {
|
||||||
|
static var shared: AppDelegate? {
|
||||||
|
NSApplication.shared.delegate as? AppDelegate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
var statusItem: NSStatusItem?
|
var statusItem: NSStatusItem?
|
||||||
var windows: [NSScreen: NSWindow] = [:]
|
var windows: [NSScreen: NSWindow] = [:]
|
||||||
|
|
@ -249,69 +255,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
private func updateWindowSizeIfNeeded() {
|
private func updateWindowSizeIfNeeded() {
|
||||||
// Calculate required size based on current state
|
// Calculate required size based on current state
|
||||||
let requiredSize = calculateRequiredNotchSize()
|
let requiredSize = calculateRequiredNotchSize()
|
||||||
|
let animateResize = shouldAnimateResize(for: requiredSize)
|
||||||
if Defaults[.showOnAllDisplays] {
|
resizeWindows(to: requiredSize, animated: animateResize, force: false)
|
||||||
// Update all windows if size has changed (multi-display mode)
|
|
||||||
for (screen, window) in windows {
|
|
||||||
if window.frame.size != requiredSize {
|
|
||||||
// Calculate center position BEFORE any changes
|
|
||||||
let screenFrame = screen.frame
|
|
||||||
let centerX = screenFrame.origin.x + (screenFrame.width / 2)
|
|
||||||
let newX = centerX - (requiredSize.width / 2)
|
|
||||||
let newY = screenFrame.origin.y + screenFrame.height - requiredSize.height
|
|
||||||
|
|
||||||
// Stop any existing animations first
|
|
||||||
NSAnimationContext.runAnimationGroup { _ in
|
|
||||||
window.animator().setFrame(window.frame, display: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animate window resize smoothly
|
|
||||||
NSAnimationContext.runAnimationGroup { context in
|
|
||||||
context.duration = 0.25
|
|
||||||
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
||||||
context.allowsImplicitAnimation = true
|
|
||||||
window.animator().setFrame(NSRect(
|
|
||||||
x: newX,
|
|
||||||
y: newY,
|
|
||||||
width: requiredSize.width,
|
|
||||||
height: requiredSize.height
|
|
||||||
), display: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Update single window if size has changed (single display mode)
|
|
||||||
if let window = window, window.frame.size != requiredSize {
|
|
||||||
// Find the screen this window is on
|
|
||||||
let currentScreen = NSScreen.screens.first { screen in
|
|
||||||
screen.frame.intersects(window.frame)
|
|
||||||
} ?? NSScreen.main ?? NSScreen.screens.first!
|
|
||||||
|
|
||||||
// Calculate center position BEFORE any changes
|
|
||||||
let screenFrame = currentScreen.frame
|
|
||||||
let centerX = screenFrame.origin.x + (screenFrame.width / 2)
|
|
||||||
let newX = centerX - (requiredSize.width / 2)
|
|
||||||
let newY = screenFrame.origin.y + screenFrame.height - requiredSize.height
|
|
||||||
|
|
||||||
// Stop any existing animations first
|
|
||||||
NSAnimationContext.runAnimationGroup { _ in
|
|
||||||
window.animator().setFrame(window.frame, display: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animate window resize smoothly
|
|
||||||
NSAnimationContext.runAnimationGroup { context in
|
|
||||||
context.duration = 0.25
|
|
||||||
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
||||||
context.allowsImplicitAnimation = true
|
|
||||||
window.animator().setFrame(NSRect(
|
|
||||||
x: newX,
|
|
||||||
y: newY,
|
|
||||||
width: requiredSize.width,
|
|
||||||
height: requiredSize.height
|
|
||||||
), display: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func calculateRequiredNotchSize() -> CGSize {
|
private func calculateRequiredNotchSize() -> CGSize {
|
||||||
|
|
@ -331,7 +276,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use minimalistic or normal size based on settings
|
// Use minimalistic or normal size based on settings
|
||||||
let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
||||||
|
|
||||||
|
if Defaults[.enableMinimalisticUI] && vm.notchState == .open {
|
||||||
|
let reminderCount = ReminderLiveActivityManager.shared.activeWindowReminders.count
|
||||||
|
let extraHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: reminderCount)
|
||||||
|
baseSize.height += extraHeight
|
||||||
|
}
|
||||||
|
|
||||||
// Only apply dynamic sizing when on stats tab and stats are enabled
|
// Only apply dynamic sizing when on stats tab and stats are enabled
|
||||||
guard coordinator.currentView == .stats && Defaults[.enableStatsFeature] else {
|
guard coordinator.currentView == .stats && Defaults[.enableStatsFeature] else {
|
||||||
|
|
@ -358,6 +309,54 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
// Width stays constant - no horizontal expansion
|
// Width stays constant - no horizontal expansion
|
||||||
return CGSize(width: baseSize.width, height: requiredHeight)
|
return CGSize(width: baseSize.width, height: requiredHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureWindowSize(_ size: CGSize, animated: Bool, force: Bool = false) {
|
||||||
|
resizeWindows(to: size, animated: animated, force: force)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resizeWindows(to size: CGSize, animated: Bool, force: Bool) {
|
||||||
|
guard size.width > 0, size.height > 0 else { return }
|
||||||
|
|
||||||
|
if Defaults[.showOnAllDisplays] {
|
||||||
|
for (screen, window) in windows {
|
||||||
|
if force || window.frame.size != size {
|
||||||
|
resizeWindow(window, on: screen, to: size, animated: animated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let window {
|
||||||
|
let screen = window.screen ?? NSScreen.screens.first { $0.frame.intersects(window.frame) } ?? NSScreen.main ?? NSScreen.screens.first
|
||||||
|
guard let screen else { return }
|
||||||
|
if force || window.frame.size != size {
|
||||||
|
resizeWindow(window, on: screen, to: size, animated: animated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resizeWindow(_ window: NSWindow, on screen: NSScreen, to size: CGSize, animated: Bool) {
|
||||||
|
let screenFrame = screen.frame
|
||||||
|
let centerX = screenFrame.midX
|
||||||
|
let newX = centerX - (size.width / 2)
|
||||||
|
let newY = screenFrame.origin.y + screenFrame.height - size.height
|
||||||
|
let targetFrame = NSRect(x: newX, y: newY, width: size.width, height: size.height)
|
||||||
|
|
||||||
|
if animated {
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.25
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||||
|
context.allowsImplicitAnimation = true
|
||||||
|
window.animator().setFrame(targetFrame, display: true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.setFrame(targetFrame, display: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldAnimateResize(for newSize: CGSize) -> Bool {
|
||||||
|
if Defaults[.enableMinimalisticUI] && !ReminderLiveActivityManager.shared.activeWindowReminders.isEmpty {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
|
||||||
|
|
@ -417,6 +416,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
self?.debouncedUpdateWindowSize()
|
self?.debouncedUpdateWindowSize()
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
ReminderLiveActivityManager.shared.$activeWindowReminders
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.debouncedUpdateWindowSize()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
Defaults.publisher(.enableShortcuts, options: []).sink { change in
|
Defaults.publisher(.enableShortcuts, options: []).sink { change in
|
||||||
KeyboardShortcuts.isEnabled = change.newValue
|
KeyboardShortcuts.isEnabled = change.newValue
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
|
||||||
|
|
@ -5604,6 +5604,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Copy event link" : {
|
||||||
|
"comment" : "A tooltip for a button that copies the link of a reminder event to the clipboard.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Copy Latest Crash Report" : {
|
"Copy Latest Crash Report" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -14487,6 +14491,10 @@
|
||||||
},
|
},
|
||||||
"Open Google AI Studio" : {
|
"Open Google AI Studio" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Open in Calendar" : {
|
||||||
|
"comment" : "A button label that opens a calendar with the event details.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Open Model Settings" : {
|
"Open Model Settings" : {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,16 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Defaults
|
import Defaults
|
||||||
|
|
||||||
|
#if canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct MinimalisticMusicPlayerView: View {
|
struct MinimalisticMusicPlayerView: View {
|
||||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
let albumArtNamespace: Namespace.ID
|
let albumArtNamespace: Namespace.ID
|
||||||
@Default(.showMediaOutputControl) var showMediaOutputControl
|
@Default(.showMediaOutputControl) var showMediaOutputControl
|
||||||
|
@ObservedObject private var reminderManager = ReminderLiveActivityManager.shared
|
||||||
|
@Default(.enableReminderLiveActivity) private var enableReminderLiveActivity
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|
@ -58,13 +64,298 @@ struct MinimalisticMusicPlayerView: View {
|
||||||
// Compact playback controls
|
// Compact playback controls
|
||||||
playbackControls
|
playbackControls
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
reminderList
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.top, -15)
|
.padding(.top, -15)
|
||||||
.padding(.bottom, 3)
|
.padding(.bottom, shouldShowReminderList ? ReminderLiveActivityManager.listBottomPadding : ReminderLiveActivityManager.baselineMinimalisticBottomPadding)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var reminderEntries: [ReminderLiveActivityManager.ReminderEntry] {
|
||||||
|
reminderManager.activeWindowReminders
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldShowReminderList: Bool {
|
||||||
|
enableReminderLiveActivity && !reminderEntries.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private var reminderListHeight: CGFloat {
|
||||||
|
ReminderLiveActivityManager.additionalHeight(forRowCount: reminderEntries.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var reminderList: some View {
|
||||||
|
MinimalisticReminderEventListView(reminders: reminderEntries)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.frame(height: reminderListHeight, alignment: .top)
|
||||||
|
.opacity(shouldShowReminderList ? 1 : 0)
|
||||||
|
.animation(.easeInOut(duration: 0.18), value: shouldShowReminderList)
|
||||||
|
.environmentObject(vm)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private struct MinimalisticReminderEventListView: View {
|
||||||
|
let reminders: [ReminderLiveActivityManager.ReminderEntry]
|
||||||
|
|
||||||
|
private let textFont = Font.system(size: 13, weight: .semibold)
|
||||||
|
private let separatorSpacing: CGFloat = 10
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: ReminderLiveActivityManager.listRowSpacing) {
|
||||||
|
ForEach(reminders) { entry in
|
||||||
|
MinimalisticReminderEventRow(entry: entry, textFont: textFont, separatorSpacing: separatorSpacing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, ReminderLiveActivityManager.listTopPadding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MinimalisticReminderEventRow: View {
|
||||||
|
let entry: ReminderLiveActivityManager.ReminderEntry
|
||||||
|
let textFont: Font
|
||||||
|
let separatorSpacing: CGFloat
|
||||||
|
|
||||||
|
@EnvironmentObject private var vm: DynamicIslandViewModel
|
||||||
|
@State private var didCopyLink = false
|
||||||
|
@State private var copyResetToken: UUID?
|
||||||
|
@State private var isDetailsPopoverPresented = false
|
||||||
|
@State private var isHoveringDetailsPopover = false
|
||||||
|
|
||||||
|
private let indicatorHeight: CGFloat = 20
|
||||||
|
|
||||||
|
private static let timeFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: separatorSpacing) {
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(eventColor)
|
||||||
|
.frame(width: 8, height: indicatorHeight)
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(entry.event.title)
|
||||||
|
.font(textFont)
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
if let timeText {
|
||||||
|
Text(timeText)
|
||||||
|
.font(.system(size: 12, weight: .regular))
|
||||||
|
.foregroundStyle(Color.white.opacity(0.6))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.layoutPriority(1)
|
||||||
|
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
|
||||||
|
HStack(spacing: separatorSpacing) {
|
||||||
|
if let url = linkURL {
|
||||||
|
Button {
|
||||||
|
copyToClipboard(url: url)
|
||||||
|
triggerCopyFeedback()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: didCopyLink ? "checkmark.circle.fill" : "link")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(didCopyLink ? Color.green : Color.white.opacity(0.85))
|
||||||
|
.symbolRenderingMode(.monochrome)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: didCopyLink)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Copy event link")
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasDetails {
|
||||||
|
Button {
|
||||||
|
isDetailsPopoverPresented.toggle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.white.opacity(0.85))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.popover(isPresented: $isDetailsPopoverPresented, arrowEdge: .top) {
|
||||||
|
MinimalisticReminderDetailsView(
|
||||||
|
entry: entry,
|
||||||
|
linkURL: linkURL,
|
||||||
|
onHoverChanged: { hovering in
|
||||||
|
isHoveringDetailsPopover = hovering
|
||||||
|
updatePopoverActivity()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onDisappear {
|
||||||
|
isHoveringDetailsPopover = false
|
||||||
|
updatePopoverActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: isDetailsPopoverPresented) { _, presented in
|
||||||
|
if !presented {
|
||||||
|
isHoveringDetailsPopover = false
|
||||||
|
updatePopoverActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: ReminderLiveActivityManager.listRowHeight)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
openInCalendar()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
copyResetToken = nil
|
||||||
|
didCopyLink = false
|
||||||
|
vm.isReminderPopoverActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var eventColor: Color {
|
||||||
|
Color(nsColor: entry.event.calendar.color).ensureMinimumBrightness(factor: 0.7)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasDetails: Bool {
|
||||||
|
let location = entry.event.location?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let notes = entry.event.notes?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return !location.isEmpty || !notes.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private var linkURL: URL? {
|
||||||
|
entry.event.url ?? entry.event.calendarAppURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeText: String? {
|
||||||
|
Self.timeFormatter.string(from: entry.event.start)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePopoverActivity() {
|
||||||
|
vm.isReminderPopoverActive = isDetailsPopoverPresented && isHoveringDetailsPopover
|
||||||
|
}
|
||||||
|
|
||||||
|
private func triggerCopyFeedback() {
|
||||||
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.65)) {
|
||||||
|
didCopyLink = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = UUID()
|
||||||
|
copyResetToken = token
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [token] in
|
||||||
|
guard copyResetToken == token else { return }
|
||||||
|
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
didCopyLink = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if copyResetToken == token {
|
||||||
|
copyResetToken = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copyToClipboard(url: URL) {
|
||||||
|
#if canImport(AppKit)
|
||||||
|
let pasteboard = NSPasteboard.general
|
||||||
|
pasteboard.clearContents()
|
||||||
|
pasteboard.setString(url.absoluteString, forType: .string)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openInCalendar() {
|
||||||
|
#if canImport(AppKit)
|
||||||
|
guard let url = linkURL else { return }
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MinimalisticReminderDetailsView: View {
|
||||||
|
let entry: ReminderLiveActivityManager.ReminderEntry
|
||||||
|
let linkURL: URL?
|
||||||
|
var onHoverChanged: (Bool) -> Void = { _ in }
|
||||||
|
|
||||||
|
private let detailFont = Font.system(size: 13, weight: .regular)
|
||||||
|
private let smallLabelFont = Font.system(size: 12, weight: .semibold)
|
||||||
|
private static let timeFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(entry.event.title)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
if let timeRange = timeRangeText {
|
||||||
|
detailRow(icon: "clock", label: "Time", value: timeRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let location = entry.event.location, !location.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
detailRow(icon: "mappin.and.ellipse", label: "Location", value: location)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let notes = entry.event.notes, !notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
detailRow(icon: "note.text", label: "Notes", value: notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let url = linkURL {
|
||||||
|
Button {
|
||||||
|
open(url: url)
|
||||||
|
} label: {
|
||||||
|
Label("Open in Calendar", systemImage: "arrow.up.right.square")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.buttonStyle(.link)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(minWidth: 220)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.fill(Color.black.opacity(0.92))
|
||||||
|
)
|
||||||
|
.onHover { hovering in
|
||||||
|
onHoverChanged(hovering)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
onHoverChanged(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detailRow(icon: String, label: String, value: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Label(label, systemImage: icon)
|
||||||
|
.font(smallLabelFont)
|
||||||
|
.foregroundStyle(Color.white.opacity(0.8))
|
||||||
|
Text(value)
|
||||||
|
.font(detailFont)
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeRangeText: String? {
|
||||||
|
let startText = Self.timeFormatter.string(from: entry.event.start)
|
||||||
|
let endText = Self.timeFormatter.string(from: entry.event.end)
|
||||||
|
return startText == endText ? startText : "\(startText) – \(endText)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func open(url: URL) {
|
||||||
|
#if canImport(AppKit)
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
// MARK: - Visualizer
|
// MARK: - Visualizer
|
||||||
|
|
||||||
@Default(.useMusicVisualizer) var useMusicVisualizer
|
@Default(.useMusicVisualizer) var useMusicVisualizer
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,8 @@ struct SettingsView: View {
|
||||||
NavigationLink(value: "Media") {
|
NavigationLink(value: "Media") {
|
||||||
Label("Media", systemImage: "play.laptopcomputer")
|
Label("Media", systemImage: "play.laptopcomputer")
|
||||||
}
|
}
|
||||||
if !enableMinimalisticUI {
|
NavigationLink(value: "Calendar") {
|
||||||
NavigationLink(value: "Calendar") {
|
Label("Calendar", systemImage: "calendar")
|
||||||
Label("Calendar", systemImage: "calendar")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
NavigationLink(value: "HUD") {
|
NavigationLink(value: "HUD") {
|
||||||
Label("HUDs", systemImage: "dial.medium.fill")
|
Label("HUDs", systemImage: "dial.medium.fill")
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import Combine
|
||||||
import Defaults
|
import Defaults
|
||||||
import EventKit
|
import EventKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ReminderLiveActivityManager: ObservableObject {
|
final class ReminderLiveActivityManager: ObservableObject {
|
||||||
|
|
@ -21,9 +22,16 @@ final class ReminderLiveActivityManager: ObservableObject {
|
||||||
static let shared = ReminderLiveActivityManager()
|
static let shared = ReminderLiveActivityManager()
|
||||||
static let standardIconName = "calendar.badge.clock"
|
static let standardIconName = "calendar.badge.clock"
|
||||||
static let criticalIconName = "calendar.badge.exclamationmark"
|
static let criticalIconName = "calendar.badge.exclamationmark"
|
||||||
|
static let listRowHeight: CGFloat = 30
|
||||||
|
static let listRowSpacing: CGFloat = 8
|
||||||
|
static let listTopPadding: CGFloat = 14
|
||||||
|
static let listBottomPadding: CGFloat = 10
|
||||||
|
static let baselineMinimalisticBottomPadding: CGFloat = 3
|
||||||
|
|
||||||
@Published private(set) var activeReminder: ReminderEntry?
|
@Published private(set) var activeReminder: ReminderEntry?
|
||||||
@Published private(set) var currentDate: Date = Date()
|
@Published private(set) var currentDate: Date = Date()
|
||||||
|
@Published private(set) var upcomingEntries: [ReminderEntry] = []
|
||||||
|
@Published private(set) var activeWindowReminders: [ReminderEntry] = []
|
||||||
|
|
||||||
private var nextReminder: ReminderEntry?
|
private var nextReminder: ReminderEntry?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
@ -113,12 +121,14 @@ final class ReminderLiveActivityManager: ObservableObject {
|
||||||
pendingRefreshForce = false
|
pendingRefreshForce = false
|
||||||
refreshTask?.cancel()
|
refreshTask?.cancel()
|
||||||
refreshTask = nil
|
refreshTask = nil
|
||||||
hasShownCriticalSneakPeek = false
|
hasShownCriticalSneakPeek = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deactivateReminder() {
|
private func deactivateReminder() {
|
||||||
nextReminder = nil
|
nextReminder = nil
|
||||||
activeReminder = nil
|
activeReminder = nil
|
||||||
|
upcomingEntries = []
|
||||||
|
activeWindowReminders = []
|
||||||
cancelAllTimers()
|
cancelAllTimers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -269,6 +279,9 @@ final class ReminderLiveActivityManager: ObservableObject {
|
||||||
.compactMap { makeEntry(from: $0, leadMinutes: leadMinutes, referenceDate: referenceDate) }
|
.compactMap { makeEntry(from: $0, leadMinutes: leadMinutes, referenceDate: referenceDate) }
|
||||||
.sorted { $0.triggerDate < $1.triggerDate }
|
.sorted { $0.triggerDate < $1.triggerDate }
|
||||||
|
|
||||||
|
upcomingEntries = upcoming
|
||||||
|
updateActiveWindowReminders(for: referenceDate)
|
||||||
|
|
||||||
guard let first = upcoming.first else {
|
guard let first = upcoming.first else {
|
||||||
deactivateReminder()
|
deactivateReminder()
|
||||||
scheduleFallbackRefresh()
|
scheduleFallbackRefresh()
|
||||||
|
|
@ -311,6 +324,7 @@ final class ReminderLiveActivityManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDate = date
|
currentDate = date
|
||||||
|
updateActiveWindowReminders(for: date)
|
||||||
|
|
||||||
guard var entry = nextReminder else {
|
guard var entry = nextReminder else {
|
||||||
activeReminder = nil
|
activeReminder = nil
|
||||||
|
|
@ -385,4 +399,25 @@ final class ReminderLiveActivityManager: ObservableObject {
|
||||||
await evaluateCurrentState(at: now)
|
await evaluateCurrentState(at: now)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateActiveWindowReminders(for date: Date) {
|
||||||
|
let filtered = upcomingEntries.filter { entry in
|
||||||
|
entry.triggerDate <= date && entry.event.start >= date
|
||||||
|
}
|
||||||
|
if filtered != activeWindowReminders {
|
||||||
|
activeWindowReminders = filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func additionalHeight(forRowCount rowCount: Int) -> CGFloat {
|
||||||
|
guard rowCount > 0 else { return 0 }
|
||||||
|
let rows = CGFloat(rowCount)
|
||||||
|
let spacing = CGFloat(max(rowCount - 1, 0)) * listRowSpacing
|
||||||
|
let bottomDelta = max(listBottomPadding - baselineMinimalisticBottomPadding, 0)
|
||||||
|
return listTopPadding + rows * listRowHeight + spacing + bottomDelta
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ReminderLiveActivityManager.ReminderEntry: Identifiable {
|
||||||
|
var id: String { event.id }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
@Published var isClipboardPopoverActive: Bool = false
|
@Published var isClipboardPopoverActive: Bool = false
|
||||||
@Published var isColorPickerPopoverActive: Bool = false
|
@Published var isColorPickerPopoverActive: Bool = false
|
||||||
@Published var isStatsPopoverActive: Bool = false
|
@Published var isStatsPopoverActive: Bool = false
|
||||||
|
@Published var isReminderPopoverActive: Bool = false
|
||||||
@Published var isMediaOutputPopoverActive: Bool = false
|
@Published var isMediaOutputPopoverActive: Bool = false
|
||||||
@Published var isTimerPopoverActive: Bool = false
|
@Published var isTimerPopoverActive: Bool = false
|
||||||
@Published var shouldRecheckHover: Bool = false
|
@Published var shouldRecheckHover: Bool = false
|
||||||
|
|
@ -44,6 +45,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
|
|
||||||
@Published var notchSize: CGSize = getClosedNotchSize()
|
@Published var notchSize: CGSize = getClosedNotchSize()
|
||||||
@Published var closedNotchSize: CGSize = getClosedNotchSize()
|
@Published var closedNotchSize: CGSize = getClosedNotchSize()
|
||||||
|
private var minimalisticReminderRowCount: Int = 0
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
destroy()
|
destroy()
|
||||||
|
|
@ -71,6 +73,19 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
setupDetectorObserver()
|
setupDetectorObserver()
|
||||||
|
|
||||||
|
ReminderLiveActivityManager.shared.$activeWindowReminders
|
||||||
|
.removeDuplicates()
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.sink { [weak self] entries in
|
||||||
|
guard let self else { return }
|
||||||
|
self.minimalisticReminderRowCount = entries.count
|
||||||
|
let updatedTarget = self.calculateDynamicNotchSize()
|
||||||
|
guard self.notchState == .open else { return }
|
||||||
|
guard self.notchSize != updatedTarget else { return }
|
||||||
|
self.notchSize = updatedTarget
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupDetectorObserver() {
|
private func setupDetectorObserver() {
|
||||||
|
|
@ -124,8 +139,21 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func open() {
|
func open() {
|
||||||
|
let targetSize = calculateDynamicNotchSize()
|
||||||
|
|
||||||
|
let applyWindowResize: () -> Void = {
|
||||||
|
guard let delegate = AppDelegate.shared else { return }
|
||||||
|
delegate.ensureWindowSize(targetSize, animated: false, force: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if Thread.isMainThread {
|
||||||
|
applyWindowResize()
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async(execute: applyWindowResize)
|
||||||
|
}
|
||||||
|
|
||||||
withAnimation(.bouncy) {
|
withAnimation(.bouncy) {
|
||||||
self.notchSize = calculateDynamicNotchSize()
|
self.notchSize = targetSize
|
||||||
self.notchState = .open
|
self.notchState = .open
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,7 +163,12 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
|
|
||||||
private func calculateDynamicNotchSize() -> CGSize {
|
private func calculateDynamicNotchSize() -> CGSize {
|
||||||
// Use minimalistic size if minimalistic UI is enabled
|
// Use minimalistic size if minimalistic UI is enabled
|
||||||
let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
||||||
|
|
||||||
|
if Defaults[.enableMinimalisticUI] {
|
||||||
|
let extraHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: minimalisticReminderRowCount)
|
||||||
|
baseSize.height += extraHeight
|
||||||
|
}
|
||||||
|
|
||||||
// Only apply dynamic sizing when on stats tab and stats are enabled
|
// Only apply dynamic sizing when on stats tab and stats are enabled
|
||||||
guard DynamicIslandViewCoordinator.shared.currentView == .stats && Defaults[.enableStatsFeature] else {
|
guard DynamicIslandViewCoordinator.shared.currentView == .stats && Defaults[.enableStatsFeature] else {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue